From 2558fb790411293636f324a22775ca485effaaa0 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:00:11 +0200 Subject: [PATCH 01/16] fix(bot-mode): keep room replicas passive until takeover is fenced --- gateway/hosted_room_contract.py | 384 +++++ gateway/hosted_room_replicas.py | 744 ++++---- gateway/hosted_room_storage.py | 1394 +++++++++++++++ gateway/hosted_rooms.py | 1504 ++--------------- tests/gateway/test_hosted_room_replicas.py | 897 ++++++++-- .../test_groups_replication_methods.py | 175 +- tui_gateway/methods_groups.py | 99 +- 7 files changed, 3097 insertions(+), 2100 deletions(-) create mode 100644 gateway/hosted_room_contract.py create mode 100644 gateway/hosted_room_storage.py diff --git a/gateway/hosted_room_contract.py b/gateway/hosted_room_contract.py new file mode 100644 index 0000000000000..780a6bf25da0c --- /dev/null +++ b/gateway/hosted_room_contract.py @@ -0,0 +1,384 @@ +"""Validation and public error contract for gateway-hosted Group Chats.""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path +from typing import Any, Mapping + + +PROTOCOL_VERSION = 2 +MAX_ROOM_ID_CHARS = 128 +MAX_EVENT_ID_CHARS = 128 +MAX_ROOM_NAME_CHARS = 200 +MAX_EVENT_KIND_CHARS = 64 +MAX_ACTOR_ID_CHARS = 128 +MAX_ACTOR_LABEL_CHARS = 200 +MAX_MEMBERS = 128 +MAX_MEMBERS_JSON_BYTES = 128 * 1024 +MAX_EVENT_JSON_BYTES = 256 * 1024 +MAX_LOG_LIMIT = 500 +MAX_LOG_PAGE_BYTES = 2 * 1024 * 1024 +MAX_ROOM_LIST_LIMIT = 500 +MAX_ACTIVE_ROOMS = 256 +MAX_DISBANDED_ROOM_TOMBSTONES = 512 +DISBANDED_ROOM_RETENTION_SECONDS = 90 * 24 * 60 * 60 +DISBANDED_REPLICA_RETENTION_SECONDS = 90 * 24 * 60 * 60 +MAX_EVENTS_PER_ROOM = 50_000 +MAX_ROOM_EVENT_BYTES = 256 * 1024 * 1024 +# Leave substantial headroom below the pre-update state.db snapshot ceiling. +# Event accounting does not include SQLite indexes or repeated room ids, so the +# logical budget must stay well below the physical-file limit. +MAX_GATEWAY_EVENT_BYTES = 16 * 1024 * 1024 +CONTROL_EVENT_COUNT_RESERVE = 64 +CONTROL_EVENT_BYTE_RESERVE = 1024 * 1024 +_JOURNAL_MODE_LOCK_RETRIES = 8 + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") +_EVENT_KIND_RE = re.compile(r"^[a-z][a-z0-9_.-]*$") +_ROOM_SCHEMA_COLUMNS = frozenset({ + "room_id", + "name", + "members_json", + "authority_gateway_id", + "authority_epoch", + "next_seq", + "event_bytes", + "revision", + "created_at", + "updated_at", + "disbanded_at", +}) +_EVENT_SCHEMA_COLUMNS = frozenset({ + "room_id", + "seq", + "event_id", + "kind", + "actor_json", + "authority_epoch", + "payload_json", + "created_at", +}) +_RETIRED_ROOM_SCHEMA_COLUMNS = frozenset({"room_id", "retired_at"}) +_LINK_SCHEMA_COLUMNS = frozenset({ + "room_id", + "member_id", + "target_url", + "target_profile", + "grant", + "catalog_json", + "cancellation_scope_id", + "trace_id", + "transport_security", + "status", + "updated_at", +}) +_REMOTE_RUN_SCHEMA_COLUMNS = frozenset({ + "room_id", + "home_install_id", + "authority_gateway_id", + "authority_epoch", + "member_id", + "task_id", + "execution_generation", + "target_install_id", + "target_profile", + "run_id", + "session_id", + "created_at", + "updated_at", +}) +_REMOTE_RUN_IDENTITY_COLUMNS = ( + "room_id", + "home_install_id", + "authority_gateway_id", + "authority_epoch", + "member_id", + "target_install_id", + "target_profile", + "task_id", + "execution_generation", +) +_REVOKED_GRANT_SCHEMA_COLUMNS = frozenset({ + "scope_key", + "expires_at", + "revoked_before", +}) +_PEER_RESERVATION_SCHEMA_COLUMNS = frozenset({ + "room_id", + "member_id", + "target_profile", + "authority_gateway_id", + "authority_epoch", + "expires_at", + "revoked_at", + "created_at", + "updated_at", +}) +_QUARANTINE_SCHEMA_COLUMNS = frozenset({"room_id", "reason", "detected_at"}) +_ROOM_RESERVATION_SCHEMA_COLUMNS = frozenset({ + "room_id", + "owner_kind", + "reserved_at", +}) +_REPLICA_RESERVATION_COLUMNS = frozenset({ + "room_id", + "created_at", +}) +_ROOM_SAFETY_TRIGGERS = frozenset({ + "trg_hosted_rooms_reject_reserved_insert", + "trg_hosted_rooms_reserve_insert", + "trg_hosted_replicas_reject_reserved_insert", + "trg_hosted_replicas_reserve_insert", + "trg_hosted_events_reject_quarantined_insert", + "trg_hosted_events_quarantine_unsafe_lineage", +}) + +_EVENT_KINDS_BY_ACTOR = { + "user": frozenset({"message.user"}), + "member": frozenset({"message.member"}), + "gateway": frozenset({ + "member.unavailable", + "room.activity", + "room.stop_requested", + "turn.deferred", + "turn.reassigned", + "turn.cancelled", + "turn.failed", + "turn.settled", + "turn.started", + }), + "system": frozenset({ + "authority.claimed", + "authority.lost", + "room.created", + "room.disbanded", + "room.members_changed", + "room.renamed", + }), +} +_ACTOR_FIELDS = frozenset({"kind", "id", "display_name", "profile", "connection_id"}) + + +class HostedRoomError(ValueError): + """Base class for invalid or conflicting hosted-room operations.""" + + +class RoomNotFoundError(HostedRoomError): + """Raised when a room does not exist or has been disbanded.""" + + +class RoomHistoryExpiredError(RoomNotFoundError): + """Raised when a retired room remains reserved after history compaction.""" + + reason = "room_history_expired" + + +class RoomConflictError(HostedRoomError): + """Raised when an idempotency key is reused for different room state.""" + + +class RoomProbeUnavailableError(HostedRoomError): + """Raised when a non-blocking ownership probe cannot read the room store.""" + + +class EventConflictError(HostedRoomError): + """Raised when an event id is reused with different immutable content.""" + + +class AuthorityConflictError(HostedRoomError): + """Raised when a stale room authority attempts to mutate hosted state.""" + + reason = "authority_conflict" + + +class AuthoritySupersededError(AuthorityConflictError): + """Raised when a successful authority claim was later superseded.""" + + +class RoomQuarantinedError(AuthorityConflictError): + """Raised when an unsafe legacy takeover must remain read-only.""" + + reason = "room_authority_quarantined" + + +def _public_limits(): + """Resolve re-exported limits late to preserve the original public seam.""" + from gateway import hosted_rooms + + return hosted_rooms + + +def default_db_path() -> Path: + """Return the gateway-wide state database for the active install.""" + from hermes_constants import get_hermes_home + + home = get_hermes_home() + root = home.parent.parent if home.parent.name == "profiles" else home + return root / "state.db" + + +def local_authority_gateway_id() -> str: + """Return the stable server-owned identity for hosted-room authority.""" + from hermes_cli.install_identity import get_install_id + + install_id = get_install_id() + if not install_id: + raise HostedRoomError("stable gateway install identity is unavailable") + return _validate_identifier( + f"install:{install_id}", + label="authority_gateway_id", + max_chars=_public_limits().MAX_ACTOR_ID_CHARS, + ) + + +def _canonical_json(value: Any, *, label: str, max_bytes: int) -> str: + try: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + except (TypeError, ValueError, RecursionError) as exc: + raise HostedRoomError(f"{label} must be JSON-serializable") from exc + if len(encoded.encode("utf-8")) > max_bytes: + raise HostedRoomError(f"{label} is too large") + return encoded + + +def _validate_identifier(value: Any, *, label: str, max_chars: int) -> str: + if not isinstance(value, str): + raise HostedRoomError(f"{label} must be a string") + value = value.strip() + if not value or len(value) > max_chars or not _IDENTIFIER_RE.fullmatch(value): + raise HostedRoomError(f"invalid {label}") + return value + + +def user_event_id(client_event_id: Any) -> str: + """Map a client retry key into the server-owned user-event namespace.""" + normalized = _validate_identifier( + client_event_id, + label="event_id", + max_chars=_public_limits().MAX_EVENT_ID_CHARS, + ) + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + return f"user:{digest}" + + +def _validate_room_name(value: Any) -> str: + if not isinstance(value, str): + raise HostedRoomError("name must be a string") + value = value.strip() + if not value or len(value) > _public_limits().MAX_ROOM_NAME_CHARS: + raise HostedRoomError("invalid room name") + return value + + +def _validate_members(value: Any) -> tuple[list[dict[str, Any]], str]: + if not isinstance(value, list): + raise HostedRoomError("members must be a list") + limits = _public_limits() + if len(value) > limits.MAX_MEMBERS: + raise HostedRoomError("too many room members") + members: list[dict[str, Any]] = [] + for member in value: + if not isinstance(member, dict): + raise HostedRoomError("each room member must be an object") + members.append(dict(member)) + encoded = _canonical_json( + members, + label="members", + max_bytes=limits.MAX_MEMBERS_JSON_BYTES, + ) + return members, encoded + + +def _legacy_members_match( + existing_json: str, + proposed: list[dict[str, Any]], +) -> bool: + """Allow adoption to add routing metadata an older room could not store.""" + + try: + existing = json.loads(existing_json) + except (TypeError, ValueError): + return False + if not isinstance(existing, list) or len(existing) != len(proposed): + return False + for previous, current in zip(existing, proposed, strict=True): + if not isinstance(previous, dict): + return False + previous = dict(previous) + current = dict(current) + previous_target = previous.pop("target", None) + current_target = current.pop("target", None) + if previous != current: + return False + if previous_target not in (None, {}) and previous_target != current_target: + return False + return True + + +def _validate_event_kind(value: Any) -> str: + if not isinstance(value, str): + raise HostedRoomError("kind must be a string") + value = value.strip() + if ( + not value + or len(value) > _public_limits().MAX_EVENT_KIND_CHARS + or not _EVENT_KIND_RE.fullmatch(value) + ): + raise HostedRoomError("invalid event kind") + return value + + +def _optional_actor_field(actor: dict[str, Any], field: str, max_chars: int) -> str: + value = actor.get(field) + if value is None: + return "" + if not isinstance(value, str): + raise HostedRoomError(f"actor.{field} must be a string") + value = value.strip() + if len(value) > max_chars: + raise HostedRoomError(f"actor.{field} is too long") + return value + + +def _validate_actor(value: Any, *, kind: str) -> tuple[dict[str, str], str]: + if not isinstance(value, dict): + raise HostedRoomError("actor must be an object") + unknown = set(value) - _ACTOR_FIELDS + if unknown: + raise HostedRoomError(f"unknown actor fields: {', '.join(sorted(unknown))}") + + actor_kind = value.get("kind") + if not isinstance(actor_kind, str) or actor_kind not in _EVENT_KINDS_BY_ACTOR: + raise HostedRoomError("invalid actor.kind") + if kind not in _EVENT_KINDS_BY_ACTOR[actor_kind]: + raise HostedRoomError(f"actor kind '{actor_kind}' cannot append '{kind}'") + + limits = _public_limits() + actor_id = _validate_identifier( + value.get("id"), + label="actor.id", + max_chars=limits.MAX_ACTOR_ID_CHARS, + ) + actor = {"kind": actor_kind, "id": actor_id} + for field, max_chars in ( + ("display_name", limits.MAX_ACTOR_LABEL_CHARS), + ("profile", limits.MAX_ACTOR_ID_CHARS), + ("connection_id", limits.MAX_ACTOR_ID_CHARS), + ): + field_value = _optional_actor_field(value, field, max_chars) + if field_value: + actor[field] = field_value + encoded = _canonical_json( + actor, + label="actor", + max_bytes=4 * 1024, + ) + return actor, encoded diff --git a/gateway/hosted_room_replicas.py b/gateway/hosted_room_replicas.py index 26b87080eda0e..ac7494d68159d 100644 --- a/gateway/hosted_room_replicas.py +++ b/gateway/hosted_room_replicas.py @@ -1,50 +1,49 @@ -"""Replica store and takeover primitives for hosted Group Chat rooms. +"""Passive replica store for hosted Group Chat rooms. The authority gateway owns a room's ordered log in ``gateway/hosted_rooms.py``. This module gives every OTHER participant gateway a durable local copy of that -log, and the fenced primitives to continue the room when the authority host -dies: - -- ``ingest_page()`` persists replay pages (``groups.log`` output, which carries - the room's authority stamp) idempotently, refusing sequence gaps and - authority-epoch regressions. -- ``promote_replica()`` instantiates the replicated log as a locally-owned - hosted room at ``epoch + 1`` with a lineage-proving ``authority.claimed`` - event, so a surviving participant can resume the room. -- ``demote_room()`` fences a returning stale authority: presented with proof of - a newer epoch, the local room records ``authority.lost`` and stops being - authoritative. - -Storage primitives only: none of these decide *when* takeover is safe. The -caller (an explicit user action today; a lease/quorum driver later) must -establish that the previous owner can no longer commit before promoting. +log: + +``ingest_page()`` persists ``groups.log`` replay pages idempotently while +refusing gaps, conflicting overlap, forged authority changes, and resurrection +after a terminal disband event. A replica deliberately remains passive: safe +takeover needs a globally exclusive lease or quorum, which this storage layer +does not provide. """ from __future__ import annotations import json +import math import sqlite3 import time +from contextlib import contextmanager from pathlib import Path from typing import Any from gateway.hosted_rooms import ( MAX_ACTOR_ID_CHARS, + MAX_EVENT_ID_CHARS, MAX_EVENT_JSON_BYTES, + MAX_GATEWAY_EVENT_BYTES, + MAX_LOG_LIMIT, + MAX_LOG_PAGE_BYTES, MAX_ROOM_ID_CHARS, HostedRoomError, - RoomConflictError, _canonical_json, _connect, + _prune_disbanded_replicas_locked, + _prune_disbanded_rooms_locked, _transaction, + _validate_actor, + _validate_event_kind, _validate_identifier, _validate_members, _validate_room_name, - local_authority_gateway_id, ) MAX_REPLICA_ROOMS = 256 -MAX_REPLICA_EVENT_BYTES = 256 * 1024 * 1024 +MAX_REPLICA_EVENT_BYTES = MAX_GATEWAY_EVENT_BYTES class ReplicaError(HostedRoomError): @@ -55,8 +54,16 @@ class ReplicaGapError(ReplicaError): """A page does not start at the replica's next expected sequence.""" -class ReplicaEpochRegressionError(ReplicaError): - """A page or demotion carries an older authority epoch than stored.""" +class ReplicaHistoryExpiredError(ReplicaError): + """A compacted replica keeps its identity but no longer has replay data.""" + + reason = "replica_history_expired" + + +class ReplicaLineageUnverifiedError(ReplicaError): + """A replica cannot prove the complete authority lineage it was given.""" + + reason = "replica_lineage_unverified" def _initialize_replica_schema(conn: sqlite3.Connection) -> None: @@ -71,7 +78,10 @@ def _initialize_replica_schema(conn: sqlite3.Connection) -> None: latest_seq INTEGER NOT NULL DEFAULT 0, event_bytes INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, - updated_at REAL NOT NULL + updated_at REAL NOT NULL, + disbanded_at REAL, + quarantined_at REAL, + quarantine_reason TEXT )""" ) conn.execute( @@ -87,19 +97,134 @@ def _initialize_replica_schema(conn: sqlite3.Connection) -> None: PRIMARY KEY (room_id, seq) )""" ) + columns = { + str(row["name"]) + for row in conn.execute("PRAGMA table_info(hosted_room_replicas)") + } + if "disbanded_at" not in columns: + conn.execute("ALTER TABLE hosted_room_replicas ADD COLUMN disbanded_at REAL") + if "quarantined_at" not in columns: + conn.execute("ALTER TABLE hosted_room_replicas ADD COLUMN quarantined_at REAL") + if "quarantine_reason" not in columns: + conn.execute("ALTER TABLE hosted_room_replicas ADD COLUMN quarantine_reason TEXT") + conn.execute( + """UPDATE hosted_room_replicas + SET disbanded_at=( + SELECT MIN(created_at) + FROM hosted_room_replica_events + WHERE hosted_room_replica_events.room_id = + hosted_room_replicas.room_id + AND kind='room.disbanded' + ) + WHERE disbanded_at IS NULL + AND EXISTS ( + SELECT 1 FROM hosted_room_replica_events + WHERE hosted_room_replica_events.room_id = + hosted_room_replicas.room_id + AND kind='room.disbanded' + )""" + ) -def _replica_transaction(db_path: Path | str): - return _transaction(db_path, immediate=True) +def _audit_existing_replicas_locked(conn: sqlite3.Connection) -> None: + """Quarantine lineage written by the pre-fix replica implementation.""" + for row in conn.execute( + """SELECT room_id, authority_gateway_id, authority_epoch, last_seq, + latest_seq, event_bytes, disbanded_at, quarantine_reason + FROM hosted_room_replicas""" + ).fetchall(): + room_id = str(row["room_id"]) + events = conn.execute( + """SELECT seq, event_id, authority_epoch, kind, actor_json, + payload_json, created_at + FROM hosted_room_replica_events + WHERE room_id=? ORDER BY seq""", + (room_id,), + ).fetchall() + reasons: list[str] = [] + seqs = [int(event["seq"]) for event in events] + event_ids = [str(event["event_id"]) for event in events] + last_seq = int(row["last_seq"]) + latest_seq = int(row["latest_seq"]) + if int(row["authority_epoch"]) != 1: + reasons.append("unverified_authority_epoch") + if seqs != list(range(1, last_seq + 1)): + reasons.append("non_contiguous_history") + if len(set(event_ids)) != len(event_ids): + reasons.append("duplicate_event_id") + if latest_seq < last_seq: + reasons.append("coverage_regression") + disband_positions = [ + index for index, event in enumerate(events) + if event["kind"] == "room.disbanded" + ] + if disband_positions and disband_positions != [len(events) - 1]: + reasons.append("events_after_disband") + if disband_positions and last_seq != latest_seq: + reasons.append("incomplete_terminal_history") + if any( + event["authority_epoch"] != int(row["authority_epoch"]) + for event in events + ): + reasons.append("mixed_authority_lineage") + try: + _validate_identifier( + row["authority_gateway_id"], + label="authority_gateway_id", + max_chars=MAX_ACTOR_ID_CHARS, + ) + for event in events: + kind = _validate_event_kind(event["kind"]) + _validate_identifier( + event["event_id"], + label="event_id", + max_chars=MAX_EVENT_ID_CHARS, + ) + actor, _ = _validate_actor( + json.loads(event["actor_json"]), kind=kind + ) + if ( + actor["kind"] == "gateway" + and actor["id"] != str(row["authority_gateway_id"]) + ): + reasons.append("gateway_actor_authority_mismatch") + payload = json.loads(event["payload_json"]) + if not isinstance(payload, dict): + raise ReplicaError("event payload is not an object") + if not math.isfinite(float(event["created_at"])): + raise ReplicaError("event timestamp is not finite") + except (HostedRoomError, TypeError, ValueError, json.JSONDecodeError): + reasons.append("invalid_event_shape") + + recomputed_bytes = sum( + len(str(event["event_id"]).encode("utf-8")) + + len(str(event["kind"]).encode("utf-8")) + + len(str(event["actor_json"]).encode("utf-8")) + + len(str(event["payload_json"]).encode("utf-8")) + for event in events + ) + if recomputed_bytes != int(row["event_bytes"]): + conn.execute( + "UPDATE hosted_room_replicas SET event_bytes=? WHERE room_id=?", + (recomputed_bytes, room_id), + ) + if reasons and row["quarantine_reason"] is None: + conn.execute( + """UPDATE hosted_room_replicas + SET quarantined_at=?, quarantine_reason=? + WHERE room_id=?""", + (time.time(), reasons[0], room_id), + ) -def _ensure_schema(db_path: Path | str) -> None: - conn = _connect(db_path) - try: - with conn: - _initialize_replica_schema(conn) - finally: - conn.close() +@contextmanager +def _replica_transaction(db_path: Path | str): + with _transaction(db_path, immediate=True) as conn: + _initialize_replica_schema(conn) + # A still-running #99047 process can write after migration. Re-audit + # inside the same write transaction before every read or extension. + _audit_existing_replicas_locked(conn) + yield conn def _event_bytes(event: dict[str, Any]) -> int: @@ -119,13 +244,24 @@ def _event_bytes(event: dict[str, Any]) -> int: ) -def _validate_page(page: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: +def _validate_non_negative_int(value: Any, *, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ReplicaError(f"{label} must be a non-negative integer") + return value + + +def _validate_page( + page: Any, +) -> tuple[list[dict[str, Any]], dict[str, Any], int, int, bool]: if not isinstance(page, dict): raise ReplicaError("page must be an object") + _canonical_json(page, label="page", max_bytes=MAX_LOG_PAGE_BYTES) events = page.get("events") authority = page.get("authority") if not isinstance(events, list): raise ReplicaError("page.events must be a list") + if len(events) > MAX_LOG_LIMIT: + raise ReplicaError(f"page.events cannot exceed {MAX_LOG_LIMIT} events") if not isinstance(authority, dict): raise ReplicaError("page.authority is required for replication") gateway_id = _validate_identifier( @@ -136,6 +272,20 @@ def _validate_page(page: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: epoch = authority.get("epoch") if isinstance(epoch, bool) or not isinstance(epoch, int) or epoch < 1: raise ReplicaError("page.authority.epoch must be a positive integer") + cursor = _validate_non_negative_int(page.get("cursor"), label="page.cursor") + latest_seq = _validate_non_negative_int( + page.get("latest_seq"), label="page.latest_seq" + ) + has_more = page.get("has_more") + if not isinstance(has_more, bool): + raise ReplicaError("page.has_more must be a boolean") + if cursor > latest_seq: + raise ReplicaError("page.cursor cannot exceed page.latest_seq") + if has_more != (cursor < latest_seq): + raise ReplicaError("page.has_more does not match its replay cursor") + + normalized_events: list[dict[str, Any]] = [] + event_ids: set[str] = set() previous_seq: int | None = None for event in events: if not isinstance(event, dict): @@ -146,14 +296,69 @@ def _validate_page(page: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: if previous_seq is not None and seq != previous_seq + 1: raise ReplicaGapError("page events must be contiguous") previous_seq = seq - for field in ("event_id", "kind"): - if not isinstance(event.get(field), str) or not event[field]: - raise ReplicaError(f"event.{field} must be a non-empty string") - if not isinstance(event.get("actor"), dict): - raise ReplicaError("event.actor must be an object") - if "payload" not in event: - raise ReplicaError("event.payload is required") - return events, {"gateway_id": gateway_id, "epoch": epoch} + event_room_id = _validate_identifier( + event.get("room_id"), + label="event.room_id", + max_chars=MAX_ROOM_ID_CHARS, + ) + event_id = _validate_identifier( + event.get("event_id"), + label="event.event_id", + max_chars=MAX_EVENT_ID_CHARS, + ) + if event_id in event_ids: + raise ReplicaError("page repeats an event_id") + event_ids.add(event_id) + kind = _validate_event_kind(event.get("kind")) + actor, actor_json = _validate_actor(event.get("actor"), kind=kind) + if actor["kind"] == "gateway" and actor["id"] != gateway_id: + raise ReplicaError("gateway actor does not match page authority") + payload = event.get("payload") + if not isinstance(payload, dict): + raise ReplicaError("event.payload must be an object") + payload_json = _canonical_json( + payload, label="payload", max_bytes=MAX_EVENT_JSON_BYTES + ) + event_epoch = event.get("authority_epoch") + if ( + isinstance(event_epoch, bool) + or not isinstance(event_epoch, int) + or event_epoch < 1 + or event_epoch > epoch + ): + raise ReplicaError("event.authority_epoch is outside the page lineage") + created_at = event.get("created_at") + if ( + isinstance(created_at, bool) + or not isinstance(created_at, (int, float)) + or not math.isfinite(float(created_at)) + ): + raise ReplicaError("event.created_at must be a finite number") + normalized_events.append( + { + "room_id": event_room_id, + "seq": seq, + "event_id": event_id, + "kind": kind, + "actor": actor, + "actor_json": actor_json, + "authority_epoch": event_epoch, + "payload": payload, + "payload_json": payload_json, + "created_at": float(created_at), + } + ) + if normalized_events and normalized_events[-1]["seq"] != cursor: + raise ReplicaError("page.cursor must equal the last returned sequence") + if not normalized_events and cursor != latest_seq: + raise ReplicaError("an incomplete replay page must include events") + return ( + normalized_events, + {"gateway_id": gateway_id, "epoch": epoch}, + cursor, + latest_seq, + has_more, + ) def ingest_page( @@ -176,19 +381,43 @@ def ingest_page( ) room_name = _validate_room_name(room_name) _, members_json = _validate_members(members) - events, authority = _validate_page(page) + events, authority, _cursor, latest_seq, _has_more = _validate_page(page) + for event in events: + if event["room_id"] != room_id: + raise ReplicaError("page contains an event for a different room") now = time.time() if now is None else float(now) - _ensure_schema(db_path) - with _replica_transaction(db_path) as conn: - _initialize_replica_schema(conn) + _prune_disbanded_replicas_locked(conn, now=now) + if conn.execute( + "SELECT 1 FROM hosted_rooms WHERE room_id=?", (room_id,) + ).fetchone(): + raise ReplicaError("room_id is already locally authoritative") + if conn.execute( + "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", (room_id,) + ).fetchone(): + raise ReplicaError("room_id is permanently retired on this gateway") row = conn.execute( - """SELECT authority_gateway_id, authority_epoch, last_seq, - latest_seq, event_bytes + """SELECT name, members_json, authority_gateway_id, authority_epoch, + last_seq, latest_seq, event_bytes, disbanded_at, + quarantined_at, quarantine_reason FROM hosted_room_replicas WHERE room_id=?""", (room_id,), ).fetchone() if row is None: + _prune_disbanded_replicas_locked( + conn, + now=None, + max_replica_rooms=max(0, MAX_REPLICA_ROOMS - 1), + ) + reservation = conn.execute( + """SELECT owner_kind FROM hosted_room_id_reservations + WHERE room_id=?""", + (room_id,), + ).fetchone() + if reservation is not None and reservation["owner_kind"] == "replica": + raise ReplicaHistoryExpiredError( + "replica history expired; room_id remains permanently retired" + ) count = conn.execute( "SELECT COUNT(*) FROM hosted_room_replicas" ).fetchone()[0] @@ -196,34 +425,116 @@ def ingest_page( raise ReplicaError("replica room capacity exhausted") stored_epoch = 0 last_seq = 0 - stored_bytes = 0 + stored_latest = 0 + disbanded_at = None + if authority["epoch"] != 1: + raise ReplicaLineageUnverifiedError( + "replica lineage is incomplete; the first authority epoch is required" + ) else: stored_epoch = int(row["authority_epoch"]) last_seq = int(row["last_seq"]) - stored_bytes = int(row["event_bytes"]) - - if authority["epoch"] < stored_epoch: - raise ReplicaEpochRegressionError( - "page authority epoch is older than the stored replica epoch" - ) + stored_latest = int(row["latest_seq"]) + disbanded_at = row["disbanded_at"] + if row["quarantine_reason"] is not None: + raise ReplicaError( + "stored replica is quarantined: " + str(row["quarantine_reason"]) + ) + if row["name"] != room_name or row["members_json"] != members_json: + raise ReplicaError("replica metadata conflicts with stored state") + if ( + row["authority_gateway_id"] != authority["gateway_id"] + or stored_epoch != authority["epoch"] + ): + raise ReplicaLineageUnverifiedError( + "replica authority changed without a verified takeover lineage" + ) + if latest_seq < stored_latest: + raise ReplicaError("page.latest_seq regresses stored replica coverage") + + for event in events: + if event["authority_epoch"] != stored_epoch and row is not None: + raise ReplicaError("event authority conflicts with stored replica lineage") + existing = conn.execute( + """SELECT seq, event_id, kind, actor_json, authority_epoch, + payload_json, created_at + FROM hosted_room_replica_events + WHERE room_id=? AND (seq=? OR event_id=?)""", + (room_id, int(event["seq"]), event["event_id"]), + ).fetchall() + for stored in existing: + if ( + int(stored["seq"]) != int(event["seq"]) + or stored["event_id"] != event["event_id"] + or stored["kind"] != event["kind"] + or stored["actor_json"] != event["actor_json"] + or stored["authority_epoch"] != event["authority_epoch"] + or stored["payload_json"] != event["payload_json"] + or float(stored["created_at"]) != event["created_at"] + ): + raise ReplicaError("replayed event conflicts with stored history") + if int(event["seq"]) <= last_seq and not existing: + raise ReplicaError("stored replica history is incomplete") new_events = [e for e in events if int(e["seq"]) > last_seq] if new_events and int(new_events[0]["seq"]) != last_seq + 1: raise ReplicaGapError( "page skips sequences the replica has not stored" ) - added_bytes = 0 - for event in new_events: - size = _event_bytes(event) - if stored_bytes + added_bytes + size > MAX_REPLICA_EVENT_BYTES: - raise ReplicaError("replica event storage exhausted") - actor_json = _canonical_json( - event["actor"], label="actor", max_bytes=4 * 1024 + if disbanded_at is not None and new_events: + raise ReplicaError("a disbanded Group Chat cannot accept later events") + disband_indexes = [ + index for index, event in enumerate(new_events) + if event["kind"] == "room.disbanded" + ] + if disband_indexes and disband_indexes != [len(new_events) - 1]: + raise ReplicaError("room.disbanded must be the terminal event") + if disband_indexes and int(new_events[-1]["seq"]) != latest_seq: + raise ReplicaError("room.disbanded must complete the source history") + + event_sizes = [_event_bytes(event) for event in new_events] + added_bytes = sum(event_sizes) + gateway_bytes = int( + conn.execute( + """SELECT + COALESCE((SELECT SUM(event_bytes) FROM hosted_rooms), 0) + + COALESCE((SELECT SUM(event_bytes) + FROM hosted_room_replicas), 0)""" + ).fetchone()[0] + ) + if gateway_bytes + added_bytes > MAX_REPLICA_EVENT_BYTES: + replica_bytes = int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_room_replicas" + ).fetchone()[0] + ) + _prune_disbanded_rooms_locked( + conn, + now=None, + max_gateway_event_bytes=max( + 0, MAX_REPLICA_EVENT_BYTES - added_bytes - replica_bytes + ), + ) + hosted_bytes = int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" + ).fetchone()[0] ) - payload_json = _canonical_json( - event["payload"], label="payload", max_bytes=MAX_EVENT_JSON_BYTES + _prune_disbanded_replicas_locked( + conn, + now=None, + max_replica_event_bytes=max( + 0, MAX_REPLICA_EVENT_BYTES - added_bytes - hosted_bytes + ), ) - epoch_value = event.get("authority_epoch") + gateway_bytes = hosted_bytes + int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_room_replicas" + ).fetchone()[0] + ) + if gateway_bytes + added_bytes > MAX_REPLICA_EVENT_BYTES: + raise ReplicaError("replica event storage exhausted") + for event in new_events: conn.execute( """INSERT INTO hosted_room_replica_events (room_id, seq, event_id, kind, actor_json, authority_epoch, @@ -234,24 +545,23 @@ def ingest_page( int(event["seq"]), event["event_id"], event["kind"], - actor_json, - epoch_value, - payload_json, - float(event.get("created_at") or now), + event["actor_json"], + event["authority_epoch"], + event["payload_json"], + event["created_at"], ), ) - added_bytes += size new_last = int(new_events[-1]["seq"]) if new_events else last_seq - latest_seq = page.get("latest_seq") - if isinstance(latest_seq, bool) or not isinstance(latest_seq, int): - latest_seq = new_last + terminal_at = ( + new_events[-1]["created_at"] if disband_indexes else disbanded_at + ) if row is None: conn.execute( """INSERT INTO hosted_room_replicas (room_id, name, members_json, authority_gateway_id, authority_epoch, last_seq, latest_seq, event_bytes, - created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + created_at, updated_at, disbanded_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( room_id, room_name, @@ -259,28 +569,25 @@ def ingest_page( authority["gateway_id"], authority["epoch"], new_last, - max(latest_seq, new_last), + latest_seq, added_bytes, now, now, + terminal_at, ), ) else: conn.execute( """UPDATE hosted_room_replicas - SET name=?, members_json=?, authority_gateway_id=?, - authority_epoch=?, last_seq=?, latest_seq=?, - event_bytes=event_bytes+?, updated_at=? + SET last_seq=?, latest_seq=?, event_bytes=event_bytes+?, + updated_at=?, disbanded_at=? WHERE room_id=?""", ( - room_name, - members_json, - authority["gateway_id"], - authority["epoch"], new_last, - max(latest_seq, new_last), + latest_seq, added_bytes, now, + terminal_at, room_id, ), ) @@ -289,7 +596,7 @@ def ingest_page( "stored_seq": new_last, "ingested": len(new_events), "authority": authority, - "caught_up": new_last >= max(latest_seq, new_last), + "caught_up": new_last >= latest_seq, } @@ -298,17 +605,29 @@ def replica_state(db_path: Path | str, *, room_id: Any) -> dict[str, Any]: room_id = _validate_identifier( room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS ) - _ensure_schema(db_path) with _replica_transaction(db_path) as conn: - _initialize_replica_schema(conn) row = conn.execute( """SELECT room_id, name, members_json, authority_gateway_id, authority_epoch, last_seq, latest_seq, event_bytes, - created_at, updated_at + created_at, updated_at, disbanded_at, + quarantined_at, quarantine_reason FROM hosted_room_replicas WHERE room_id=?""", (room_id,), ).fetchone() + reservation = ( + conn.execute( + """SELECT owner_kind FROM hosted_room_id_reservations + WHERE room_id=?""", + (room_id,), + ).fetchone() + if row is None + else None + ) if row is None: + if reservation is not None and reservation["owner_kind"] == "replica": + raise ReplicaHistoryExpiredError( + "replica history expired; room_id remains permanently retired" + ) raise ReplicaError("replica not found") return { "room_id": row["room_id"], @@ -323,248 +642,11 @@ def replica_state(db_path: Path | str, *, room_id: Any) -> dict[str, Any]: "event_bytes": int(row["event_bytes"]), "created_at": float(row["created_at"]), "updated_at": float(row["updated_at"]), - } - - -def promote_replica( - db_path: Path | str, - *, - room_id: Any, - reason: Any = "authority-unreachable", - now: float | None = None, -) -> dict[str, Any]: - """Continue a replicated room on THIS gateway at ``epoch + 1``. - - Copies the replica's log into the authoritative store, appends a lineage- - proving ``authority.claimed`` event, and returns the new room state. The - old authority is fenced everywhere the claim replicates: its epoch is now - stale and every fenced primitive rejects it. - - The caller decides that takeover is safe (the previous owner can no longer - commit). This primitive only makes the takeover atomic and provable. - """ - room_id = _validate_identifier( - room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS - ) - if not isinstance(reason, str) or not reason or len(reason) > 200: - raise ReplicaError("reason must be a non-empty string of at most 200 chars") - now = time.time() if now is None else float(now) - local_gateway = local_authority_gateway_id() - _ensure_schema(db_path) - - with _replica_transaction(db_path) as conn: - _initialize_replica_schema(conn) - replica = conn.execute( - """SELECT room_id, name, members_json, authority_gateway_id, - authority_epoch, last_seq, event_bytes - FROM hosted_room_replicas WHERE room_id=?""", - (room_id,), - ).fetchone() - if replica is None: - raise ReplicaError("replica not found") - if replica["authority_gateway_id"] == local_gateway: - raise ReplicaError("this gateway already holds the room authority") - if conn.execute( - "SELECT 1 FROM hosted_rooms WHERE room_id=?", (room_id,) - ).fetchone(): - raise RoomConflictError( - "room_id already exists in the local authoritative store" - ) - if conn.execute( - "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", - (room_id,), - ).fetchone(): - raise RoomConflictError("room_id belongs to a disbanded room") - - previous_gateway = str(replica["authority_gateway_id"]) - previous_epoch = int(replica["authority_epoch"]) - target_epoch = previous_epoch + 1 - last_seq = int(replica["last_seq"]) - claim_seq = last_seq + 1 - claim_event_id = f"system:authority-claimed:{target_epoch}" - claim_actor_json = _canonical_json( - {"kind": "system", "id": "authority-control"}, - label="actor", - max_bytes=4 * 1024, - ) - claim_payload_json = _canonical_json( - { - "previous_gateway_id": previous_gateway, - "authority_gateway_id": local_gateway, - "authority_epoch": target_epoch, - "promoted_from_replica": True, - "reason": reason, - }, - label="payload", - max_bytes=MAX_EVENT_JSON_BYTES, - ) - claim_bytes = ( - len(claim_event_id.encode("utf-8")) - + len(b"authority.claimed") - + len(claim_actor_json.encode("utf-8")) - + len(claim_payload_json.encode("utf-8")) - ) - - conn.execute( - """INSERT INTO hosted_rooms - (room_id, name, members_json, authority_gateway_id, - authority_epoch, next_seq, event_bytes, revision, - created_at, updated_at, disbanded_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, NULL)""", - ( - room_id, - replica["name"], - replica["members_json"], - local_gateway, - target_epoch, - claim_seq + 1, - int(replica["event_bytes"]) + claim_bytes, - now, - now, - ), - ) - conn.execute( - """INSERT INTO hosted_room_events - (room_id, seq, event_id, kind, actor_json, authority_epoch, - payload_json, created_at) - SELECT room_id, seq, event_id, kind, actor_json, - authority_epoch, payload_json, created_at - FROM hosted_room_replica_events WHERE room_id=?""", - (room_id,), - ) - conn.execute( - """INSERT INTO hosted_room_events - (room_id, seq, event_id, kind, actor_json, authority_epoch, - payload_json, created_at) - VALUES (?, ?, ?, 'authority.claimed', ?, ?, ?, ?)""", - ( - room_id, - claim_seq, - claim_event_id, - claim_actor_json, - target_epoch, - claim_payload_json, - now, - ), - ) - conn.execute( - "DELETE FROM hosted_room_replica_events WHERE room_id=?", (room_id,) - ) - conn.execute( - "DELETE FROM hosted_room_replicas WHERE room_id=?", (room_id,) - ) - return { - "room_id": room_id, - "authority_gateway_id": local_gateway, - "authority_epoch": target_epoch, - "previous_gateway_id": previous_gateway, - "previous_epoch": previous_epoch, - "claim_seq": claim_seq, - "latest_seq": claim_seq, - } - - -def demote_room( - db_path: Path | str, - *, - room_id: Any, - observed_gateway_id: Any, - observed_epoch: Any, - now: float | None = None, -) -> dict[str, Any]: - """Fence THIS gateway's stale room authority against a proven newer epoch. - - Called when a returning gateway observes (via a replicated - ``authority.claimed`` event or a transport rejection) that another gateway - now owns the room at a higher epoch. Appends ``authority.lost`` and adopts - the observed lineage so no further local sends can be committed at the - stale epoch. Idempotent for repeated observations of the same lineage. - """ - room_id = _validate_identifier( - room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS - ) - observed_gateway_id = _validate_identifier( - observed_gateway_id, - label="observed_gateway_id", - max_chars=MAX_ACTOR_ID_CHARS, - ) - if ( - isinstance(observed_epoch, bool) - or not isinstance(observed_epoch, int) - or observed_epoch < 1 - ): - raise ReplicaError("observed_epoch must be a positive integer") - now = time.time() if now is None else float(now) - local_gateway = local_authority_gateway_id() - - with _replica_transaction(db_path) as conn: - row = conn.execute( - """SELECT authority_gateway_id, authority_epoch, next_seq - FROM hosted_rooms WHERE room_id=? AND disbanded_at IS NULL""", - (room_id,), - ).fetchone() - if row is None: - raise ReplicaError("room not found in the local authoritative store") - current_gateway = str(row["authority_gateway_id"]) - current_epoch = int(row["authority_epoch"]) - if ( - current_gateway == observed_gateway_id - and current_epoch == observed_epoch - ): - return { - "room_id": room_id, - "authority_gateway_id": current_gateway, - "authority_epoch": current_epoch, - "idempotent": True, - } - if observed_epoch <= current_epoch: - raise ReplicaEpochRegressionError( - "observed epoch does not supersede the stored authority" - ) - if current_gateway != local_gateway: - raise ReplicaError( - "room is not locally authoritative; nothing to demote" - ) - seq = int(row["next_seq"]) - lost_actor_json = _canonical_json( - {"kind": "system", "id": "authority-control"}, - label="actor", - max_bytes=4 * 1024, - ) - lost_payload_json = _canonical_json( - { - "previous_gateway_id": current_gateway, - "authority_gateway_id": observed_gateway_id, - "authority_epoch": observed_epoch, - }, - label="payload", - max_bytes=MAX_EVENT_JSON_BYTES, - ) - conn.execute( - """INSERT INTO hosted_room_events - (room_id, seq, event_id, kind, actor_json, authority_epoch, - payload_json, created_at) - VALUES (?, ?, ?, 'authority.lost', ?, ?, ?, ?)""", - ( - room_id, - seq, - f"system:authority-lost:{observed_epoch}", - lost_actor_json, - observed_epoch, - lost_payload_json, - now, - ), - ) - conn.execute( - """UPDATE hosted_rooms - SET authority_gateway_id=?, authority_epoch=?, - next_seq=next_seq+1, revision=revision+1, updated_at=? - WHERE room_id=?""", - (observed_gateway_id, observed_epoch, now, room_id), - ) - return { - "room_id": room_id, - "authority_gateway_id": observed_gateway_id, - "authority_epoch": observed_epoch, - "idempotent": False, + "disbanded_at": ( + float(row["disbanded_at"]) if row["disbanded_at"] is not None else None + ), + "safety_status": ( + "quarantined" if row["quarantine_reason"] is not None else "passive" + ), + "safety_reason": row["quarantine_reason"], } diff --git a/gateway/hosted_room_storage.py b/gateway/hosted_room_storage.py new file mode 100644 index 0000000000000..4db848854afb5 --- /dev/null +++ b/gateway/hosted_room_storage.py @@ -0,0 +1,1394 @@ +"""SQLite storage helpers for gateway-hosted Group Chats. + +This module owns schema initialization, root-database transactions, capacity, +quarantine, peer-link receipts, and row serialization. Public room operations +remain in ``gateway.hosted_rooms``. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator, Mapping, NoReturn + +from gateway.hosted_room_contract import ( + AuthorityConflictError, + DISBANDED_REPLICA_RETENTION_SECONDS, + DISBANDED_ROOM_RETENTION_SECONDS, + HostedRoomError, + MAX_ACTIVE_ROOMS, + MAX_ACTOR_ID_CHARS, + MAX_DISBANDED_ROOM_TOMBSTONES, + MAX_EVENTS_PER_ROOM, + MAX_GATEWAY_EVENT_BYTES, + MAX_ROOM_EVENT_BYTES, + MAX_ROOM_ID_CHARS, + RoomHistoryExpiredError, + RoomNotFoundError, + RoomQuarantinedError, + _EVENT_SCHEMA_COLUMNS, + _JOURNAL_MODE_LOCK_RETRIES, + _LINK_SCHEMA_COLUMNS, + _PEER_RESERVATION_SCHEMA_COLUMNS, + _QUARANTINE_SCHEMA_COLUMNS, + _REMOTE_RUN_IDENTITY_COLUMNS, + _REMOTE_RUN_SCHEMA_COLUMNS, + _REPLICA_RESERVATION_COLUMNS, + _RETIRED_ROOM_SCHEMA_COLUMNS, + _REVOKED_GRANT_SCHEMA_COLUMNS, + _ROOM_RESERVATION_SCHEMA_COLUMNS, + _ROOM_SAFETY_TRIGGERS, + _ROOM_SCHEMA_COLUMNS, + _canonical_json, + _validate_identifier, +) + + +def _public_api(): + """Resolve re-exported limits late so tests and callers can override them.""" + from gateway import hosted_rooms + + return hosted_rooms + + +def _primary_key_columns(conn: sqlite3.Connection, table: str) -> tuple[str, ...]: + return tuple( + str(row[1]) + for row in sorted( + (row for row in conn.execute(f"PRAGMA table_info({table})") if row[5]), + key=lambda row: int(row[5]), + ) + ) + + +def _migrate_remote_run_schema(conn: sqlite3.Connection) -> None: + """Fence legacy receipts behind a complete authority-lineage key.""" + + columns = { + str(row[1]) + for row in conn.execute("PRAGMA table_info(hosted_room_remote_runs)") + } + if ( + _REMOTE_RUN_SCHEMA_COLUMNS.issubset(columns) + and _primary_key_columns(conn, "hosted_room_remote_runs") + == _REMOTE_RUN_IDENTITY_COLUMNS + ): + return + + conn.execute("DROP TABLE IF EXISTS hosted_room_remote_runs_migrating") + conn.execute( + """CREATE TABLE hosted_room_remote_runs_migrating ( + room_id TEXT NOT NULL, + home_install_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + member_id TEXT NOT NULL, + task_id TEXT NOT NULL, + execution_generation INTEGER NOT NULL CHECK (execution_generation >= 1), + target_install_id TEXT NOT NULL, + target_profile TEXT NOT NULL, + run_id TEXT NOT NULL, + session_id TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY ( + room_id, home_install_id, authority_gateway_id, authority_epoch, + member_id, target_install_id, target_profile, task_id, + execution_generation + ) + )""" + ) + if columns: + home = "home_install_id" if "home_install_id" in columns else "'legacy'" + gateway = ( + "authority_gateway_id" + if "authority_gateway_id" in columns + else "'legacy'" + ) + epoch = "authority_epoch" if "authority_epoch" in columns else "1" + conn.execute( + f"""INSERT OR IGNORE INTO hosted_room_remote_runs_migrating( + room_id, home_install_id, authority_gateway_id, + authority_epoch, member_id, task_id, + execution_generation, target_install_id, target_profile, + run_id, session_id, created_at, updated_at + ) + SELECT room_id, {home}, {gateway}, {epoch}, member_id, task_id, + execution_generation, target_install_id, target_profile, + run_id, session_id, created_at, updated_at + FROM hosted_room_remote_runs""" + ) + conn.execute("DROP TABLE hosted_room_remote_runs") + conn.execute( + "ALTER TABLE hosted_room_remote_runs_migrating " + "RENAME TO hosted_room_remote_runs" + ) + + +def _initialize_schema(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_rooms ( + room_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + members_json TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL DEFAULT 1 CHECK (authority_epoch >= 1), + next_seq INTEGER NOT NULL DEFAULT 1 CHECK (next_seq >= 1), + event_bytes INTEGER NOT NULL DEFAULT 0 CHECK (event_bytes >= 0), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + disbanded_at REAL + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_events ( + room_id TEXT NOT NULL, + seq INTEGER NOT NULL CHECK (seq >= 1), + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + actor_json TEXT NOT NULL, + authority_epoch INTEGER CHECK (authority_epoch IS NULL OR authority_epoch >= 1), + payload_json TEXT NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (room_id, seq), + UNIQUE (room_id, event_id), + FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_retired_ids ( + room_id TEXT PRIMARY KEY, + retired_at REAL NOT NULL + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_links ( + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + target_url TEXT NOT NULL, + target_profile TEXT NOT NULL, + grant TEXT NOT NULL, + catalog_json TEXT NOT NULL, + cancellation_scope_id TEXT NOT NULL, + trace_id TEXT NOT NULL, + transport_security TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ready', + updated_at REAL NOT NULL, + PRIMARY KEY (room_id, member_id) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_remote_runs ( + room_id TEXT NOT NULL, + home_install_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + member_id TEXT NOT NULL, + task_id TEXT NOT NULL, + execution_generation INTEGER NOT NULL CHECK (execution_generation >= 1), + target_install_id TEXT NOT NULL, + target_profile TEXT NOT NULL, + run_id TEXT NOT NULL, + session_id TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY ( + room_id, home_install_id, authority_gateway_id, authority_epoch, + member_id, target_install_id, target_profile, task_id, + execution_generation + ) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_revoked_grants ( + scope_key TEXT PRIMARY KEY, + expires_at REAL NOT NULL, + revoked_before REAL NOT NULL + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_peer_reservations ( + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + target_profile TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + expires_at REAL NOT NULL, + revoked_at REAL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (room_id, member_id, target_profile) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_quarantine ( + room_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + detected_at REAL NOT NULL + )""" + ) + # The room-ID ledger and replica identity table live in the same root DB as + # authoritative rooms. Creating the replica table here lets SQLite enforce + # namespace safety even when an older gateway process shares this database. + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_replicas ( + room_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + members_json TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + last_seq INTEGER NOT NULL DEFAULT 0 CHECK (last_seq >= 0), + latest_seq INTEGER NOT NULL DEFAULT 0, + event_bytes INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + disbanded_at REAL, + quarantined_at REAL, + quarantine_reason TEXT + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_id_reservations ( + room_id TEXT PRIMARY KEY, + owner_kind TEXT NOT NULL CHECK (owner_kind IN ('authority', 'replica')), + reserved_at REAL NOT NULL + )""" + ) + room_columns = {row[1] for row in conn.execute("PRAGMA table_info(hosted_rooms)")} + if "authority_gateway_id" not in room_columns: + conn.execute( + "ALTER TABLE hosted_rooms " + "ADD COLUMN authority_gateway_id TEXT NOT NULL DEFAULT 'legacy'" + ) + if "authority_epoch" not in room_columns: + conn.execute( + "ALTER TABLE hosted_rooms " + "ADD COLUMN authority_epoch INTEGER NOT NULL DEFAULT 1" + ) + backfill_event_bytes = "event_bytes" not in room_columns + if backfill_event_bytes: + conn.execute( + "ALTER TABLE hosted_rooms ADD COLUMN event_bytes INTEGER NOT NULL DEFAULT 0" + ) + + event_columns = { + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_events)") + } + if "actor_json" not in event_columns: + # Draft builds before the actor contract carried no identity. Preserve + # their inert replay rows explicitly as legacy system events rather + # than guessing a user or Bot author. + legacy_actor = _canonical_json( + {"kind": "system", "id": "legacy"}, + label="actor", + max_bytes=4 * 1024, + ) + escaped_actor = legacy_actor.replace("'", "''") + conn.execute( + "ALTER TABLE hosted_room_events " + f"ADD COLUMN actor_json TEXT NOT NULL DEFAULT '{escaped_actor}'" + ) + if "authority_epoch" not in event_columns: + conn.execute( + "ALTER TABLE hosted_room_events ADD COLUMN authority_epoch INTEGER" + ) + if backfill_event_bytes: + conn.execute( + """UPDATE hosted_rooms + SET event_bytes=COALESCE(( + SELECT SUM( + length(CAST(event_id AS BLOB)) + + length(CAST(kind AS BLOB)) + + length(CAST(actor_json AS BLOB)) + + length(CAST(payload_json AS BLOB)) + ) + FROM hosted_room_events + WHERE hosted_room_events.room_id=hosted_rooms.room_id + ), 0)""" + ) + # Old schemas kept the final identity tombstone in hosted_rooms itself. + # Copy those identities before bounded history pruning can remove their + # heavier room/event payloads. This compact registry is intentionally + # permanent: a stale coordinate must never name a different Group Chat. + conn.execute( + """INSERT OR IGNORE INTO hosted_room_retired_ids (room_id, retired_at) + SELECT room_id, disbanded_at FROM hosted_rooms + WHERE disbanded_at IS NOT NULL""" + ) + _migrate_remote_run_schema(conn) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_hosted_room_events_cursor + ON hosted_room_events(room_id, seq)""" + ) + # #99047 briefly exposed local-only takeover primitives that could promote + # partial replicas or let multiple gateways claim the same next epoch. + # Preserve those logs for inspection, but never let an identifiable unsafe + # lineage keep mutating after this migration. + conn.execute( + """INSERT OR IGNORE INTO hosted_room_quarantine + (room_id, reason, detected_at) + SELECT room_id, 'unsafe_replica_promotion', MIN(created_at) + FROM hosted_room_events + WHERE kind='authority.claimed' + AND payload_json LIKE '%"promoted_from_replica":true%' + GROUP BY room_id""" + ) + conn.execute( + """INSERT OR IGNORE INTO hosted_room_quarantine + (room_id, reason, detected_at) + SELECT room_id, 'unsafe_authority_demotion', MIN(created_at) + FROM hosted_room_events + WHERE kind='authority.lost' + GROUP BY room_id""" + ) + conn.execute( + """INSERT OR IGNORE INTO hosted_room_quarantine + (room_id, reason, detected_at) + SELECT rooms.room_id, 'room_namespace_collision', rooms.updated_at + FROM hosted_rooms AS rooms + JOIN hosted_room_replicas AS replicas + ON replicas.room_id=rooms.room_id""" + ) + conn.execute( + """INSERT OR IGNORE INTO hosted_room_id_reservations + (room_id, owner_kind, reserved_at) + SELECT room_id, 'authority', created_at FROM hosted_rooms""" + ) + conn.execute( + """INSERT OR IGNORE INTO hosted_room_id_reservations + (room_id, owner_kind, reserved_at) + SELECT room_id, 'replica', created_at FROM hosted_room_replicas""" + ) + for trigger in ( + """CREATE TRIGGER IF NOT EXISTS trg_hosted_rooms_reject_reserved_insert + BEFORE INSERT ON hosted_rooms + WHEN EXISTS ( + SELECT 1 FROM hosted_room_id_reservations WHERE room_id=NEW.room_id + ) + BEGIN + SELECT RAISE(ABORT, 'room_id is already reserved'); + END""", + """CREATE TRIGGER IF NOT EXISTS trg_hosted_rooms_reserve_insert + AFTER INSERT ON hosted_rooms + BEGIN + INSERT INTO hosted_room_id_reservations + (room_id, owner_kind, reserved_at) + VALUES (NEW.room_id, 'authority', NEW.created_at); + END""", + """CREATE TRIGGER IF NOT EXISTS trg_hosted_replicas_reject_reserved_insert + BEFORE INSERT ON hosted_room_replicas + WHEN EXISTS ( + SELECT 1 FROM hosted_room_id_reservations WHERE room_id=NEW.room_id + ) + BEGIN + SELECT RAISE(ABORT, 'room_id is already reserved'); + END""", + """CREATE TRIGGER IF NOT EXISTS trg_hosted_replicas_reserve_insert + AFTER INSERT ON hosted_room_replicas + BEGIN + INSERT INTO hosted_room_id_reservations + (room_id, owner_kind, reserved_at) + VALUES (NEW.room_id, 'replica', NEW.created_at); + END""", + """CREATE TRIGGER IF NOT EXISTS trg_hosted_events_reject_quarantined_insert + BEFORE INSERT ON hosted_room_events + WHEN EXISTS ( + SELECT 1 FROM hosted_room_quarantine WHERE room_id=NEW.room_id + ) + BEGIN + SELECT RAISE(ABORT, 'room authority is quarantined'); + END""", + """CREATE TRIGGER IF NOT EXISTS trg_hosted_events_quarantine_unsafe_lineage + AFTER INSERT ON hosted_room_events + WHEN NEW.kind='authority.lost' + OR ( + NEW.kind='authority.claimed' + AND NEW.payload_json LIKE '%"promoted_from_replica":true%' + ) + BEGIN + INSERT OR IGNORE INTO hosted_room_quarantine + (room_id, reason, detected_at) + VALUES ( + NEW.room_id, + CASE + WHEN NEW.kind='authority.lost' + THEN 'unsafe_authority_demotion' + ELSE 'unsafe_replica_promotion' + END, + NEW.created_at + ); + END""", + ): + conn.execute(trigger) + if not _schema_is_current(conn): + raise HostedRoomError("hosted room schema migration did not complete") + + +def _schema_is_current(conn: sqlite3.Connection) -> bool: + room_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_rooms)") + ) + event_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_events)") + ) + retired_room_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_retired_ids)") + ) + link_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_links)") + ) + remote_run_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_remote_runs)") + ) + revoked_grant_columns = frozenset( + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_revoked_grants)") + ) + peer_reservation_columns = frozenset( + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_peer_reservations)") + ) + quarantine_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_quarantine)") + ) + reservation_columns = frozenset( + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_id_reservations)") + ) + replica_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_replicas)") + ) + if not _ROOM_SCHEMA_COLUMNS.issubset(room_columns): + return False + if not _EVENT_SCHEMA_COLUMNS.issubset(event_columns): + return False + if not _RETIRED_ROOM_SCHEMA_COLUMNS.issubset(retired_room_columns): + return False + if not _LINK_SCHEMA_COLUMNS.issubset(link_columns): + return False + if not _REMOTE_RUN_SCHEMA_COLUMNS.issubset(remote_run_columns): + return False + if ( + _primary_key_columns(conn, "hosted_room_remote_runs") + != _REMOTE_RUN_IDENTITY_COLUMNS + ): + return False + if not _REVOKED_GRANT_SCHEMA_COLUMNS.issubset(revoked_grant_columns): + return False + if not _PEER_RESERVATION_SCHEMA_COLUMNS.issubset(peer_reservation_columns): + return False + if not _QUARANTINE_SCHEMA_COLUMNS.issubset(quarantine_columns): + return False + if not _ROOM_RESERVATION_SCHEMA_COLUMNS.issubset(reservation_columns): + return False + if not _REPLICA_RESERVATION_COLUMNS.issubset(replica_columns): + return False + index = conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='index' AND name='idx_hosted_room_events_cursor'""" + ).fetchone() + triggers = frozenset( + str(row[0]) + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='trigger'" + ).fetchall() + ) + return index is not None and _ROOM_SAFETY_TRIGGERS.issubset(triggers) + + +def list_room_link_records(db_path: Path | str) -> list[dict[str, Any]]: + """Return private RoomLink records without logging or formatting grants.""" + with _transaction(db_path) as conn: + rows = conn.execute( + """SELECT room_id, member_id, target_url, target_profile, grant, + catalog_json, cancellation_scope_id, trace_id, + transport_security, status, updated_at + FROM hosted_room_links + ORDER BY room_id, member_id""" + ).fetchall() + return [dict(row) for row in rows] + + +def upsert_room_link_record( + db_path: Path | str, + *, + record: Mapping[str, Any], + max_links: int, +) -> None: + """Atomically insert or replace one private RoomLink record.""" + with _transaction(db_path, immediate=True) as conn: + existing = conn.execute( + "SELECT 1 FROM hosted_room_links WHERE room_id=? AND member_id=?", + (record["room_id"], record["member_id"]), + ).fetchone() + if existing is None: + count = int( + conn.execute("SELECT COUNT(*) FROM hosted_room_links").fetchone()[0] + ) + if count >= max_links: + raise HostedRoomError("too many stored room links") + conn.execute( + """INSERT INTO hosted_room_links( + room_id, member_id, target_url, target_profile, grant, + catalog_json, cancellation_scope_id, trace_id, + transport_security, status, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(room_id, member_id) DO UPDATE SET + target_url=excluded.target_url, + target_profile=excluded.target_profile, + grant=excluded.grant, + catalog_json=excluded.catalog_json, + cancellation_scope_id=excluded.cancellation_scope_id, + trace_id=excluded.trace_id, + transport_security=excluded.transport_security, + status=excluded.status, + updated_at=excluded.updated_at""", + ( + record["room_id"], + record["member_id"], + record["target_url"], + record["target_profile"], + record["grant"], + record["catalog_json"], + record["cancellation_scope_id"], + record["trace_id"], + record["transport_security"], + record["status"], + record["updated_at"], + ), + ) + + +def update_room_link_status( + db_path: Path | str, + *, + room_id: str, + member_id: str, + status: str, + now: float | None = None, +) -> bool: + """Persist a non-secret route health classification.""" + with _transaction(db_path, immediate=True) as conn: + cursor = conn.execute( + """UPDATE hosted_room_links SET status=?, updated_at=? + WHERE room_id=? AND member_id=?""", + ( + status, + float(now if now is not None else time.time()), + room_id, + member_id, + ), + ) + return cursor.rowcount == 1 + + +def delete_room_link_records(db_path: Path | str, *, room_id: str) -> int: + """Delete persisted peer routes after their target grants are revoked.""" + with _transaction(db_path, immediate=True) as conn: + cursor = conn.execute( + "DELETE FROM hosted_room_links WHERE room_id=?", + (room_id,), + ) + return cursor.rowcount + + +def _room_grant_scope_key(claims: Mapping[str, Any]) -> str: + """Return a stable non-secret key for one room/home/target/profile scope.""" + import hashlib + + fields = { + key: str(claims.get(key) or "") + for key in ( + "room_id", + "home_install_id", + "authority_gateway_id", + "authority_epoch", + "member_id", + "target_install_id", + "target_profile", + ) + } + if not all(fields.values()): + raise HostedRoomError("room grant scope is incomplete") + return hashlib.sha256( + json.dumps(fields, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def revoke_room_grant_scope( + db_path: Path | str, + *, + claims: Mapping[str, Any], + expires_at: float, + now: float | None = None, +) -> None: + """Revoke every grant issued at or before now for one exact room scope.""" + scope_key = _room_grant_scope_key(claims) + timestamp = float(now if now is not None else time.time()) + expiry = float(expires_at) + if expiry <= timestamp: + return + with _transaction(db_path, immediate=True) as conn: + conn.execute( + "DELETE FROM hosted_room_revoked_grants WHERE expires_at<=?", + (timestamp,), + ) + conn.execute( + """INSERT INTO hosted_room_revoked_grants( + scope_key, expires_at, revoked_before + ) VALUES (?, ?, ?) + ON CONFLICT(scope_key) DO UPDATE SET + expires_at=MAX(hosted_room_revoked_grants.expires_at, + excluded.expires_at), + revoked_before=MAX(hosted_room_revoked_grants.revoked_before, + excluded.revoked_before)""", + (scope_key, expiry, timestamp), + ) + conn.execute( + """UPDATE hosted_room_peer_reservations + SET revoked_at=?, updated_at=? + WHERE room_id=? AND member_id=? AND target_profile=? + AND authority_gateway_id=? AND authority_epoch=?""", + ( + timestamp, + timestamp, + str(claims.get("room_id") or ""), + str(claims.get("member_id") or ""), + str(claims.get("target_profile") or ""), + str(claims.get("authority_gateway_id") or ""), + int(claims.get("authority_epoch") or 0), + ), + ) + + +def reserve_peer_room( + db_path: Path | str, + *, + claims: Mapping[str, Any], + expires_at: float, + now: float | None = None, +) -> None: + """Fence direct Desktop prompts before the first peer run is admitted.""" + + timestamp = float(now if now is not None else time.time()) + expiry = float(expires_at) + if expiry <= timestamp: + raise HostedRoomError("peer room reservation must expire in the future") + values = ( + _validate_identifier( + claims.get("room_id"), label="room_id", max_chars=MAX_ROOM_ID_CHARS + ), + _validate_identifier( + claims.get("member_id"), label="member_id", max_chars=MAX_ACTOR_ID_CHARS + ), + _validate_identifier( + claims.get("target_profile"), + label="target_profile", + max_chars=MAX_ACTOR_ID_CHARS, + ), + _validate_identifier( + claims.get("authority_gateway_id"), + label="authority_gateway_id", + max_chars=MAX_ACTOR_ID_CHARS, + ), + int(claims.get("authority_epoch") or 0), + ) + if values[4] < 1: + raise HostedRoomError("authority_epoch must be positive") + with _transaction(db_path, immediate=True) as conn: + conn.execute( + "DELETE FROM hosted_room_peer_reservations WHERE expires_at<=?", + (timestamp,), + ) + authority_rows = conn.execute( + """SELECT authority_gateway_id, authority_epoch + FROM hosted_room_peer_reservations + WHERE room_id=? AND target_profile=? + AND expires_at>? AND revoked_at IS NULL""", + (values[0], values[2], timestamp), + ).fetchall() + if any( + int(row["authority_epoch"]) > values[4] + or ( + int(row["authority_epoch"]) == values[4] + and str(row["authority_gateway_id"]) != values[3] + ) + for row in authority_rows + ): + raise AuthorityConflictError("peer room reservation authority changed") + conn.execute( + """UPDATE hosted_room_peer_reservations + SET revoked_at=?, updated_at=? + WHERE room_id=? AND target_profile=? + AND authority_epoch values[4] + or ( + int(existing["authority_epoch"]) == values[4] + and str(existing["authority_gateway_id"]) != values[3] + ) + ): + raise AuthorityConflictError("peer room reservation authority changed") + conn.execute( + """INSERT INTO hosted_room_peer_reservations( + room_id, member_id, target_profile, authority_gateway_id, + authority_epoch, expires_at, revoked_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?) + ON CONFLICT(room_id, member_id, target_profile) DO UPDATE SET + authority_gateway_id=excluded.authority_gateway_id, + authority_epoch=excluded.authority_epoch, + expires_at=MAX(hosted_room_peer_reservations.expires_at, + excluded.expires_at), + revoked_at=NULL, + updated_at=excluded.updated_at""", + (*values, expiry, timestamp, timestamp), + ) + + +def peer_room_is_reserved( + db_path: Path | str, + *, + room_id: str, + target_profile: str, + now: float | None = None, +) -> bool: + """Return whether a live target-side RoomLink reservation fences Desktop.""" + + timestamp = float(now if now is not None else time.time()) + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT 1 FROM hosted_room_peer_reservations + WHERE room_id=? AND target_profile=? + AND expires_at>? AND revoked_at IS NULL + LIMIT 1""", + ( + _validate_identifier( + room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS + ), + _validate_identifier( + target_profile, + label="target_profile", + max_chars=MAX_ACTOR_ID_CHARS, + ), + timestamp, + ), + ).fetchone() + return row is not None + + +def peer_room_grant_is_current( + db_path: Path | str, + *, + claims: Mapping[str, Any], + now: float | None = None, +) -> bool: + """Require a grant to match the target's current live reservation.""" + + timestamp = float(now if now is not None else time.time()) + room_id = _validate_identifier( + claims.get("room_id"), label="room_id", max_chars=MAX_ROOM_ID_CHARS + ) + member_id = _validate_identifier( + claims.get("member_id"), label="member_id", max_chars=MAX_ACTOR_ID_CHARS + ) + target_profile = _validate_identifier( + claims.get("target_profile"), + label="target_profile", + max_chars=MAX_ACTOR_ID_CHARS, + ) + authority_gateway_id = _validate_identifier( + claims.get("authority_gateway_id"), + label="authority_gateway_id", + max_chars=MAX_ACTOR_ID_CHARS, + ) + authority_epoch = int(claims.get("authority_epoch") or 0) + if authority_epoch < 1: + raise HostedRoomError("authority_epoch must be positive") + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT 1 FROM hosted_room_peer_reservations + WHERE room_id=? AND member_id=? AND target_profile=? + AND authority_gateway_id=? AND authority_epoch=? + AND expires_at>? AND revoked_at IS NULL + LIMIT 1""", + ( + room_id, + member_id, + target_profile, + authority_gateway_id, + authority_epoch, + timestamp, + ), + ).fetchone() + return row is not None + + +def room_grant_is_revoked( + db_path: Path | str, + *, + claims: Mapping[str, Any], + now: float | None = None, +) -> bool: + """Return whether a grant predates its exact scope's revocation fence.""" + timestamp = float(now if now is not None else time.time()) + scope_key = _room_grant_scope_key(claims) + issued_at = float(claims.get("issued_at") or 0) + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT revoked_before FROM hosted_room_revoked_grants + WHERE scope_key=? AND expires_at>?""", + (scope_key, timestamp), + ).fetchone() + return row is not None and issued_at <= float(row["revoked_before"]) + + +def _remote_run_identity(record: Mapping[str, Any]) -> tuple[Any, ...]: + return tuple(record[column] for column in _REMOTE_RUN_IDENTITY_COLUMNS) + + +def upsert_remote_run_receipt( + db_path: Path | str, + *, + record: Mapping[str, Any], + now: float | None = None, +) -> None: + """Durably bind one logical peer task attempt to its remote run handle.""" + timestamp = float(now if now is not None else time.time()) + identity = _remote_run_identity(record) + with _transaction(db_path, immediate=True) as conn: + existing = conn.execute( + """SELECT * FROM hosted_room_remote_runs + WHERE room_id=? AND home_install_id=? + AND authority_gateway_id=? AND authority_epoch=? + AND member_id=? AND target_install_id=? + AND target_profile=? AND task_id=? + AND execution_generation=?""", + identity, + ).fetchone() + immutable = (*identity, record["run_id"], record["session_id"]) + if existing is not None: + stored = (*_remote_run_identity(existing), existing["run_id"], existing["session_id"]) + if stored != immutable: + raise HostedRoomError( + "remote run receipt conflicts with its logical task" + ) + conn.execute( + """UPDATE hosted_room_remote_runs SET updated_at=? + WHERE room_id=? AND home_install_id=? + AND authority_gateway_id=? AND authority_epoch=? + AND member_id=? AND target_install_id=? + AND target_profile=? AND task_id=? + AND execution_generation=?""", + (timestamp, *identity), + ) + return + conn.execute( + """INSERT INTO hosted_room_remote_runs( + room_id, home_install_id, authority_gateway_id, + authority_epoch, member_id, target_install_id, + target_profile, task_id, execution_generation, run_id, + session_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + *immutable, + timestamp, + timestamp, + ), + ) + + +def list_remote_run_receipts( + db_path: Path | str, + *, + room_id: str | None = None, + target_profile: str | None = None, + session_id: str | None = None, +) -> list[dict[str, Any]]: + """Return remote run handles in durable task order.""" + conditions: list[str] = [] + values: list[Any] = [] + for column, value in ( + ("room_id", room_id), + ("target_profile", target_profile), + ("session_id", session_id), + ): + if value is not None: + conditions.append(f"{column}=?") + values.append(value) + where = f" WHERE {' AND '.join(conditions)}" if conditions else "" + with _transaction(db_path) as conn: + rows = conn.execute( + "SELECT * FROM hosted_room_remote_runs" + + where + + " ORDER BY created_at, task_id, execution_generation", + values, + ).fetchall() + return [dict(row) for row in rows] + + +def remote_run_receipt( + db_path: Path | str, + *, + record: Mapping[str, Any], +) -> dict[str, Any] | None: + """Return the exact durable remote run handle for one task attempt.""" + identity = _remote_run_identity(record) + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT * FROM hosted_room_remote_runs + WHERE room_id=? AND home_install_id=? + AND authority_gateway_id=? AND authority_epoch=? + AND member_id=? AND target_install_id=? + AND target_profile=? AND task_id=? + AND execution_generation=?""", + identity, + ).fetchone() + return dict(row) if row is not None else None + + +def _connect(db_path: Path | str) -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + + path = Path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=10) + conn.row_factory = sqlite3.Row + try: + for attempt in range(_JOURNAL_MODE_LOCK_RETRIES): + try: + apply_wal_with_fallback(conn, db_label="state.db (hosted_rooms)") + break + except sqlite3.OperationalError as exc: + if ( + str(exc).lower() != "database is locked" + or attempt + 1 == _JOURNAL_MODE_LOCK_RETRIES + ): + raise + # SQLite's journal-mode pragma may not honor the connection's + # busy timeout while another first opener initializes the DB, + # especially on Windows. Retry only that transient lock class. + time.sleep(0.01 * (2**attempt)) + conn.execute("PRAGMA foreign_keys=ON") + if _schema_is_current(conn): + return conn + # Multiple profile gateways share this root database. Serialize every + # draft-schema transition in SQLite itself so a crash rolls back the + # whole DDL/data migration and another process can safely retry it. + conn.execute("BEGIN IMMEDIATE") + _public_api()._initialize_schema(conn) + conn.commit() + except Exception: + conn.rollback() + conn.close() + raise + return conn + + +def _read_connection(db_path: Path | str) -> sqlite3.Connection: + """Open the room store without steady-state journal or migration writes.""" + + path = Path(db_path) + if not path.is_file(): + initialized = _connect(path) + initialized.close() + conn = sqlite3.connect(path, timeout=10) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + if _schema_is_current(conn): + return conn + conn.close() + migrated = _connect(path) + migrated.close() + conn = sqlite3.connect(path, timeout=10) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +@contextmanager +def _transaction( + db_path: Path | str, *, immediate: bool = False +) -> Iterator[sqlite3.Connection]: + conn = _connect(db_path) + try: + if immediate: + conn.execute("BEGIN IMMEDIATE") + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _raise_room_not_found(conn: sqlite3.Connection, room_id: str) -> NoReturn: + retained = conn.execute( + "SELECT 1 FROM hosted_rooms WHERE room_id=?", + (room_id,), + ).fetchone() + if retained is not None: + # A retained disband tombstone still has replayable history. The + # caller simply did not opt into reading disbanded rooms. + raise RoomNotFoundError("hosted room not found") + retired = conn.execute( + "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", + (room_id,), + ).fetchone() + if retired is not None: + raise RoomHistoryExpiredError( + "Group Chat history expired; room_id remains permanently retired" + ) + raise RoomNotFoundError("hosted room not found") + + +def _table_exists(conn: sqlite3.Connection, table: str) -> bool: + return ( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (table,), + ).fetchone() + is not None + ) + + +def _quarantine_reason_locked(conn: sqlite3.Connection, room_id: str) -> str | None: + row = conn.execute( + "SELECT reason FROM hosted_room_quarantine WHERE room_id=?", (room_id,) + ).fetchone() + return str(row["reason"]) if row is not None else None + + +def _raise_if_quarantined(conn: sqlite3.Connection, room_id: str) -> None: + reason = _quarantine_reason_locked(conn, room_id) + if reason is not None: + raise RoomQuarantinedError( + "This Group Chat has an unverified authority takeover and is read-only " + f"until its history is reconciled ({reason})." + ) + + +def _replica_reserves_room_id_locked(conn: sqlite3.Connection, room_id: str) -> bool: + if not conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_room_replicas'""" + ).fetchone(): + return False + return ( + conn.execute( + "SELECT 1 FROM hosted_room_replicas WHERE room_id=?", (room_id,) + ).fetchone() + is not None + ) + + +def _room_id_reservation_kind_locked( + conn: sqlite3.Connection, room_id: str +) -> str | None: + row = conn.execute( + "SELECT owner_kind FROM hosted_room_id_reservations WHERE room_id=?", + (room_id,), + ).fetchone() + return str(row["owner_kind"]) if row is not None else None + + +def _room_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: + room = { + "room_id": row["room_id"], + "name": row["name"], + "members": json.loads(row["members_json"]), + "authority_gateway_id": row["authority_gateway_id"], + "authority_epoch": int(row["authority_epoch"]), + "revision": int(row["revision"]), + "created_at": float(row["created_at"]), + "updated_at": float(row["updated_at"]), + "idempotent": idempotent, + } + if "disbanded_at" in row.keys() and row["disbanded_at"] is not None: + room["disbanded_at"] = float(row["disbanded_at"]) + if "next_seq" in row.keys(): + room["latest_seq"] = int(row["next_seq"]) - 1 + if "quarantine_reason" in row.keys() and row["quarantine_reason"] is not None: + room["safety_status"] = "authority_quarantined" + room["safety_reason"] = str(row["quarantine_reason"]) + return room + + +def _event_storage_bytes( + *, event_id: str, kind: str, actor_json: str, payload_json: str +) -> int: + return len((event_id + kind + actor_json + payload_json).encode("utf-8")) + + +def _replica_event_bytes_locked(conn: sqlite3.Connection) -> int: + """Return passive-replica bytes when the optional replica table exists.""" + if not conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_room_replicas'""" + ).fetchone(): + return 0 + return int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_room_replicas" + ).fetchone()[0] + ) + + +def _assert_event_capacity( + conn: sqlite3.Connection, + *, + room: sqlite3.Row, + additional_bytes: int, + allow_control: bool = False, +) -> None: + limits = _public_api() + event_limit = limits.MAX_EVENTS_PER_ROOM + ( + limits.CONTROL_EVENT_COUNT_RESERVE if allow_control else 0 + ) + room_byte_limit = limits.MAX_ROOM_EVENT_BYTES + ( + limits.CONTROL_EVENT_BYTE_RESERVE if allow_control else 0 + ) + gateway_byte_limit = limits.MAX_GATEWAY_EVENT_BYTES + ( + limits.CONTROL_EVENT_BYTE_RESERVE if allow_control else 0 + ) + if int(room["next_seq"]) - 1 >= event_limit: + raise HostedRoomError( + "This Group Chat reached its history limit. Start a new Group Chat to continue." + ) + room_bytes = int(room["event_bytes"]) + if room_bytes + additional_bytes > room_byte_limit: + raise HostedRoomError( + "This Group Chat reached its storage limit. Start a new Group Chat to continue." + ) + replica_bytes = _replica_event_bytes_locked(conn) + gateway_bytes = replica_bytes + int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" + ).fetchone()[0] + ) + if gateway_bytes + additional_bytes > gateway_byte_limit: + _prune_disbanded_rooms_locked( + conn, + now=None, + max_gateway_event_bytes=max( + 0, gateway_byte_limit - additional_bytes - replica_bytes + ), + ) + gateway_bytes = replica_bytes + int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" + ).fetchone()[0] + ) + if gateway_bytes + additional_bytes > gateway_byte_limit: + hosted_bytes = int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" + ).fetchone()[0] + ) + _prune_disbanded_replicas_locked( + conn, + now=None, + max_replica_event_bytes=max( + 0, gateway_byte_limit - additional_bytes - hosted_bytes + ), + ) + gateway_bytes = _replica_event_bytes_locked(conn) + hosted_bytes + if gateway_bytes + additional_bytes > gateway_byte_limit: + raise HostedRoomError( + "Group Chat storage is full on this host. Delete an old Group Chat and try again." + ) + +def _prune_disbanded_rooms_locked( + conn: sqlite3.Connection, + *, + now: float | None, + max_gateway_event_bytes: int | None = None, +) -> int: + limits = _public_api() + candidates: set[str] = set() + if now is not None: + cutoff = now - limits.DISBANDED_ROOM_RETENTION_SECONDS + candidates.update( + str(row["room_id"]) + for row in conn.execute( + """SELECT room_id FROM hosted_rooms + WHERE disbanded_at IS NOT NULL AND disbanded_at<=?""", + (cutoff,), + ).fetchall() + ) + candidates.update( + str(row["room_id"]) + for row in conn.execute( + """SELECT room_id FROM hosted_rooms + WHERE disbanded_at IS NOT NULL + ORDER BY disbanded_at DESC, room_id ASC + LIMIT -1 OFFSET ?""", + (limits.MAX_DISBANDED_ROOM_TOMBSTONES,), + ).fetchall() + ) + if max_gateway_event_bytes is not None: + retained_bytes = int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" + ).fetchone()[0] + ) + if retained_bytes > max_gateway_event_bytes: + for row in conn.execute( + """SELECT room_id, event_bytes FROM hosted_rooms + WHERE disbanded_at IS NOT NULL + ORDER BY disbanded_at ASC, room_id ASC""" + ).fetchall(): + room_id = str(row["room_id"]) + if room_id not in candidates: + candidates.add(room_id) + retained_bytes -= int(row["event_bytes"]) + if retained_bytes <= max_gateway_event_bytes: + break + if not candidates: + return 0 + + placeholders = ",".join("?" for _ in candidates) + room_ids = tuple(sorted(candidates)) + conn.execute( + f"""INSERT OR IGNORE INTO hosted_room_retired_ids (room_id, retired_at) + SELECT room_id, disbanded_at FROM hosted_rooms + WHERE room_id IN ({placeholders}) AND disbanded_at IS NOT NULL""", + room_ids, + ) + dependent_tables = ( + "hosted_room_policy_transcript_state", + "hosted_room_policy_transcript", + "hosted_room_policy_publications", + "hosted_room_policy_watermarks", + "hosted_room_policy_events", + "hosted_room_policy_threads", + "hosted_room_policy_cursors", + "hosted_room_driver_tasks", + "hosted_room_driver_leases", + "hosted_room_remote_runs", + "hosted_room_links", + "hosted_room_peer_reservations", + "hosted_room_events", + ) + for table in dependent_tables: + if _table_exists(conn, table): + conn.execute( + f"DELETE FROM {table} WHERE room_id IN ({placeholders})", + room_ids, + ) + conn.execute( + f"DELETE FROM hosted_rooms WHERE room_id IN ({placeholders})", + room_ids, + ) + return len(room_ids) + + +def _prune_disbanded_replicas_locked( + conn: sqlite3.Connection, + *, + now: float | None, + max_replica_event_bytes: int | None = None, + max_replica_rooms: int | None = None, +) -> int: + """Reclaim terminal replica payload while its room-ID reservation remains.""" + limits = _public_api() + candidates: set[str] = set() + if now is not None: + cutoff = now - limits.DISBANDED_REPLICA_RETENTION_SECONDS + candidates.update( + str(row["room_id"]) + for row in conn.execute( + """SELECT room_id FROM hosted_room_replicas + WHERE disbanded_at IS NOT NULL AND disbanded_at<=? + AND last_seq=latest_seq AND quarantine_reason IS NULL""", + (cutoff,), + ).fetchall() + ) + if max_replica_event_bytes is not None: + retained_bytes = int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_room_replicas" + ).fetchone()[0] + ) + if retained_bytes > max_replica_event_bytes: + for row in conn.execute( + """SELECT room_id, event_bytes FROM hosted_room_replicas + WHERE disbanded_at IS NOT NULL AND last_seq=latest_seq + AND quarantine_reason IS NULL + ORDER BY disbanded_at ASC, room_id ASC""" + ).fetchall(): + candidates.add(str(row["room_id"])) + retained_bytes -= int(row["event_bytes"]) + if retained_bytes <= max_replica_event_bytes: + break + if max_replica_rooms is not None: + retained_rooms = int( + conn.execute("SELECT COUNT(*) FROM hosted_room_replicas").fetchone()[0] + ) + if retained_rooms > max_replica_rooms: + for row in conn.execute( + """SELECT room_id FROM hosted_room_replicas + WHERE disbanded_at IS NOT NULL AND last_seq=latest_seq + AND quarantine_reason IS NULL + ORDER BY disbanded_at ASC, room_id ASC""" + ).fetchall(): + candidates.add(str(row["room_id"])) + retained_rooms -= 1 + if retained_rooms <= max_replica_rooms: + break + if not candidates: + return 0 + placeholders = ",".join("?" for _ in candidates) + room_ids = tuple(sorted(candidates)) + conn.execute( + f"DELETE FROM hosted_room_replica_events WHERE room_id IN ({placeholders})", + room_ids, + ) + deleted = conn.execute( + f"""DELETE FROM hosted_room_replicas + WHERE room_id IN ({placeholders}) AND disbanded_at IS NOT NULL + AND last_seq=latest_seq AND quarantine_reason IS NULL""", + room_ids, + ) + return max(0, int(deleted.rowcount)) + + +def prune_disbanded_rooms( + db_path: Path | str, + *, + now: float | None = None, +) -> int: + """Purge deleted Group Chat payloads while reserving their identities.""" + + timestamp = time.time() if now is None else float(now) + with _transaction(db_path, immediate=True) as conn: + return _prune_disbanded_rooms_locked(conn, now=timestamp) + + +def _event_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: + return { + "room_id": row["room_id"], + "seq": int(row["seq"]), + "event_id": row["event_id"], + "kind": row["kind"], + "actor": json.loads(row["actor_json"]), + "authority_epoch": ( + int(row["authority_epoch"]) if row["authority_epoch"] is not None else None + ), + "payload": json.loads(row["payload_json"]), + "created_at": float(row["created_at"]), + "idempotent": idempotent, + } diff --git a/gateway/hosted_rooms.py b/gateway/hosted_rooms.py index 0272495295b7e..63515b0df12aa 100644 --- a/gateway/hosted_rooms.py +++ b/gateway/hosted_rooms.py @@ -1,1414 +1,93 @@ -"""Durable state for gateway-hosted Bot Mode rooms. +"""Durable operations for gateway-hosted Bot Mode rooms. -This module owns only hosted-room identity and its append-only event log. It -does not deliver events, lease relay work, or run agent turns; those concerns -belong to the relay and the future hosted-room driver. Keeping that boundary -explicit lets the room log compose with a durable relay without creating a -second transport queue. - -The caller supplies the database path so tests and alternate gateway layouts -can isolate state. Production handlers use the gateway's root ``state.db``. +The public API in this module owns room identity and its append-only event log. +Validation lives in ``hosted_room_contract`` and root-DB mechanics live in +``hosted_room_storage``. """ from __future__ import annotations import hashlib import json -import re import sqlite3 import time -from contextlib import contextmanager from pathlib import Path -from typing import Any, Iterator, Mapping, NoReturn - - -PROTOCOL_VERSION = 2 -MAX_ROOM_ID_CHARS = 128 -MAX_EVENT_ID_CHARS = 128 -MAX_ROOM_NAME_CHARS = 200 -MAX_EVENT_KIND_CHARS = 64 -MAX_ACTOR_ID_CHARS = 128 -MAX_ACTOR_LABEL_CHARS = 200 -MAX_MEMBERS = 128 -MAX_MEMBERS_JSON_BYTES = 128 * 1024 -MAX_EVENT_JSON_BYTES = 256 * 1024 -MAX_LOG_LIMIT = 500 -MAX_LOG_PAGE_BYTES = 2 * 1024 * 1024 -MAX_ROOM_LIST_LIMIT = 500 -MAX_ACTIVE_ROOMS = 256 -MAX_DISBANDED_ROOM_TOMBSTONES = 512 -DISBANDED_ROOM_RETENTION_SECONDS = 90 * 24 * 60 * 60 -MAX_EVENTS_PER_ROOM = 50_000 -MAX_ROOM_EVENT_BYTES = 256 * 1024 * 1024 -# Leave substantial headroom below the pre-update state.db snapshot ceiling. -# Event accounting does not include SQLite indexes or repeated room ids, so the -# logical budget must stay well below the physical-file limit. -MAX_GATEWAY_EVENT_BYTES = 16 * 1024 * 1024 -CONTROL_EVENT_COUNT_RESERVE = 64 -CONTROL_EVENT_BYTE_RESERVE = 1024 * 1024 -_JOURNAL_MODE_LOCK_RETRIES = 8 - -_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") -_EVENT_KIND_RE = re.compile(r"^[a-z][a-z0-9_.-]*$") -_ROOM_SCHEMA_COLUMNS = frozenset({ - "room_id", - "name", - "members_json", - "authority_gateway_id", - "authority_epoch", - "next_seq", - "event_bytes", - "revision", - "created_at", - "updated_at", - "disbanded_at", -}) -_EVENT_SCHEMA_COLUMNS = frozenset({ - "room_id", - "seq", - "event_id", - "kind", - "actor_json", - "authority_epoch", - "payload_json", - "created_at", -}) -_RETIRED_ROOM_SCHEMA_COLUMNS = frozenset({"room_id", "retired_at"}) -_LINK_SCHEMA_COLUMNS = frozenset({ - "room_id", - "member_id", - "target_url", - "target_profile", - "grant", - "catalog_json", - "cancellation_scope_id", - "trace_id", - "transport_security", - "status", - "updated_at", -}) -_REMOTE_RUN_SCHEMA_COLUMNS = frozenset({ - "room_id", - "home_install_id", - "authority_gateway_id", - "authority_epoch", - "member_id", - "task_id", - "execution_generation", - "target_install_id", - "target_profile", - "run_id", - "session_id", - "created_at", - "updated_at", -}) -_REMOTE_RUN_IDENTITY_COLUMNS = ( - "room_id", - "home_install_id", - "authority_gateway_id", - "authority_epoch", - "member_id", - "target_install_id", - "target_profile", - "task_id", - "execution_generation", +from typing import Any + +from gateway.hosted_room_contract import ( + AuthorityConflictError, + AuthoritySupersededError, + CONTROL_EVENT_BYTE_RESERVE, + CONTROL_EVENT_COUNT_RESERVE, + DISBANDED_REPLICA_RETENTION_SECONDS, + DISBANDED_ROOM_RETENTION_SECONDS, + EventConflictError, + HostedRoomError, + MAX_ACTOR_ID_CHARS, + MAX_ACTOR_LABEL_CHARS, + MAX_ACTIVE_ROOMS, + MAX_DISBANDED_ROOM_TOMBSTONES, + MAX_EVENT_ID_CHARS, + MAX_EVENT_KIND_CHARS, + MAX_EVENT_JSON_BYTES, + MAX_EVENTS_PER_ROOM, + MAX_GATEWAY_EVENT_BYTES, + MAX_LOG_LIMIT, + MAX_LOG_PAGE_BYTES, + MAX_MEMBERS, + MAX_MEMBERS_JSON_BYTES, + MAX_ROOM_EVENT_BYTES, + MAX_ROOM_ID_CHARS, + MAX_ROOM_LIST_LIMIT, + MAX_ROOM_NAME_CHARS, + PROTOCOL_VERSION, + RoomConflictError, + RoomHistoryExpiredError, + RoomNotFoundError, + RoomProbeUnavailableError, + RoomQuarantinedError, + _canonical_json, + _legacy_members_match, + _optional_actor_field, + _validate_actor, + _validate_event_kind, + _validate_identifier, + _validate_members, + _validate_room_name, + default_db_path, + local_authority_gateway_id, + user_event_id, +) +from gateway.hosted_room_storage import ( + _assert_event_capacity, + _connect, + _event_from_row, + _event_storage_bytes, + _initialize_schema, + _prune_disbanded_replicas_locked, + _prune_disbanded_rooms_locked, + _raise_if_quarantined, + _raise_room_not_found, + _read_connection, + _replica_reserves_room_id_locked, + _room_from_row, + _room_id_reservation_kind_locked, + _schema_is_current, + _transaction, + delete_room_link_records, + list_remote_run_receipts, + list_room_link_records, + peer_room_grant_is_current, + peer_room_is_reserved, + prune_disbanded_rooms, + remote_run_receipt, + reserve_peer_room, + revoke_room_grant_scope, + room_grant_is_revoked, + update_room_link_status, + upsert_remote_run_receipt, + upsert_room_link_record, ) -_REVOKED_GRANT_SCHEMA_COLUMNS = frozenset({ - "scope_key", - "expires_at", - "revoked_before", -}) -_PEER_RESERVATION_SCHEMA_COLUMNS = frozenset({ - "room_id", - "member_id", - "target_profile", - "authority_gateway_id", - "authority_epoch", - "expires_at", - "revoked_at", - "created_at", - "updated_at", -}) - -_EVENT_KINDS_BY_ACTOR = { - "user": frozenset({"message.user"}), - "member": frozenset({"message.member"}), - "gateway": frozenset({ - "member.unavailable", - "room.activity", - "room.stop_requested", - "turn.deferred", - "turn.reassigned", - "turn.cancelled", - "turn.failed", - "turn.settled", - "turn.started", - }), - "system": frozenset({ - "authority.claimed", - "authority.lost", - "room.created", - "room.disbanded", - "room.members_changed", - "room.renamed", - }), -} -_ACTOR_FIELDS = frozenset({"kind", "id", "display_name", "profile", "connection_id"}) - - -class HostedRoomError(ValueError): - """Base class for invalid or conflicting hosted-room operations.""" - - -class RoomNotFoundError(HostedRoomError): - """Raised when a room does not exist or has been disbanded.""" - - -class RoomHistoryExpiredError(RoomNotFoundError): - """Raised when a retired room remains reserved after history compaction.""" - - reason = "room_history_expired" - - -class RoomConflictError(HostedRoomError): - """Raised when an idempotency key is reused for different room state.""" - - -class RoomProbeUnavailableError(HostedRoomError): - """Raised when a non-blocking ownership probe cannot read the room store.""" - - -class EventConflictError(HostedRoomError): - """Raised when an event id is reused with different immutable content.""" - - -class AuthorityConflictError(HostedRoomError): - """Raised when a stale room authority attempts to mutate hosted state.""" - - reason = "authority_conflict" - - -class AuthoritySupersededError(AuthorityConflictError): - """Raised when a successful authority claim was later superseded.""" - - -def _primary_key_columns(conn: sqlite3.Connection, table: str) -> tuple[str, ...]: - return tuple( - str(row[1]) - for row in sorted( - (row for row in conn.execute(f"PRAGMA table_info({table})") if row[5]), - key=lambda row: int(row[5]), - ) - ) - - -def _migrate_remote_run_schema(conn: sqlite3.Connection) -> None: - """Fence legacy receipts behind a complete authority-lineage key.""" - - columns = { - str(row[1]) - for row in conn.execute("PRAGMA table_info(hosted_room_remote_runs)") - } - if ( - _REMOTE_RUN_SCHEMA_COLUMNS.issubset(columns) - and _primary_key_columns(conn, "hosted_room_remote_runs") - == _REMOTE_RUN_IDENTITY_COLUMNS - ): - return - - conn.execute("DROP TABLE IF EXISTS hosted_room_remote_runs_migrating") - conn.execute( - """CREATE TABLE hosted_room_remote_runs_migrating ( - room_id TEXT NOT NULL, - home_install_id TEXT NOT NULL, - authority_gateway_id TEXT NOT NULL, - authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), - member_id TEXT NOT NULL, - task_id TEXT NOT NULL, - execution_generation INTEGER NOT NULL CHECK (execution_generation >= 1), - target_install_id TEXT NOT NULL, - target_profile TEXT NOT NULL, - run_id TEXT NOT NULL, - session_id TEXT NOT NULL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY ( - room_id, home_install_id, authority_gateway_id, authority_epoch, - member_id, target_install_id, target_profile, task_id, - execution_generation - ) - )""" - ) - if columns: - home = "home_install_id" if "home_install_id" in columns else "'legacy'" - gateway = ( - "authority_gateway_id" - if "authority_gateway_id" in columns - else "'legacy'" - ) - epoch = "authority_epoch" if "authority_epoch" in columns else "1" - conn.execute( - f"""INSERT OR IGNORE INTO hosted_room_remote_runs_migrating( - room_id, home_install_id, authority_gateway_id, - authority_epoch, member_id, task_id, - execution_generation, target_install_id, target_profile, - run_id, session_id, created_at, updated_at - ) - SELECT room_id, {home}, {gateway}, {epoch}, member_id, task_id, - execution_generation, target_install_id, target_profile, - run_id, session_id, created_at, updated_at - FROM hosted_room_remote_runs""" - ) - conn.execute("DROP TABLE hosted_room_remote_runs") - conn.execute( - "ALTER TABLE hosted_room_remote_runs_migrating " - "RENAME TO hosted_room_remote_runs" - ) - - -def default_db_path() -> Path: - """Return the gateway-wide state database for the active install.""" - from hermes_constants import get_hermes_home - - home = get_hermes_home() - root = home.parent.parent if home.parent.name == "profiles" else home - return root / "state.db" - - -def local_authority_gateway_id() -> str: - """Return the stable server-owned identity for hosted-room authority.""" - from hermes_cli.install_identity import get_install_id - - install_id = get_install_id() - if not install_id: - raise HostedRoomError("stable gateway install identity is unavailable") - return _validate_identifier( - f"install:{install_id}", - label="authority_gateway_id", - max_chars=MAX_ACTOR_ID_CHARS, - ) - - -def _canonical_json(value: Any, *, label: str, max_bytes: int) -> str: - try: - encoded = json.dumps( - value, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ) - except (TypeError, ValueError, RecursionError) as exc: - raise HostedRoomError(f"{label} must be JSON-serializable") from exc - if len(encoded.encode("utf-8")) > max_bytes: - raise HostedRoomError(f"{label} is too large") - return encoded - - -def _validate_identifier(value: Any, *, label: str, max_chars: int) -> str: - if not isinstance(value, str): - raise HostedRoomError(f"{label} must be a string") - value = value.strip() - if not value or len(value) > max_chars or not _IDENTIFIER_RE.fullmatch(value): - raise HostedRoomError(f"invalid {label}") - return value - - -def user_event_id(client_event_id: Any) -> str: - """Map a client retry key into the server-owned user-event namespace.""" - normalized = _validate_identifier( - client_event_id, - label="event_id", - max_chars=MAX_EVENT_ID_CHARS, - ) - digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() - return f"user:{digest}" - - -def _validate_room_name(value: Any) -> str: - if not isinstance(value, str): - raise HostedRoomError("name must be a string") - value = value.strip() - if not value or len(value) > MAX_ROOM_NAME_CHARS: - raise HostedRoomError("invalid room name") - return value - - -def _validate_members(value: Any) -> tuple[list[dict[str, Any]], str]: - if not isinstance(value, list): - raise HostedRoomError("members must be a list") - if len(value) > MAX_MEMBERS: - raise HostedRoomError("too many room members") - members: list[dict[str, Any]] = [] - for member in value: - if not isinstance(member, dict): - raise HostedRoomError("each room member must be an object") - members.append(dict(member)) - encoded = _canonical_json( - members, - label="members", - max_bytes=MAX_MEMBERS_JSON_BYTES, - ) - return members, encoded - - -def _legacy_members_match( - existing_json: str, - proposed: list[dict[str, Any]], -) -> bool: - """Allow adoption to add routing metadata an older room could not store.""" - - try: - existing = json.loads(existing_json) - except (TypeError, ValueError): - return False - if not isinstance(existing, list) or len(existing) != len(proposed): - return False - for previous, current in zip(existing, proposed, strict=True): - if not isinstance(previous, dict): - return False - previous = dict(previous) - current = dict(current) - previous_target = previous.pop("target", None) - current_target = current.pop("target", None) - if previous != current: - return False - if previous_target not in (None, {}) and previous_target != current_target: - return False - return True - - -def _validate_event_kind(value: Any) -> str: - if not isinstance(value, str): - raise HostedRoomError("kind must be a string") - value = value.strip() - if ( - not value - or len(value) > MAX_EVENT_KIND_CHARS - or not _EVENT_KIND_RE.fullmatch(value) - ): - raise HostedRoomError("invalid event kind") - return value - - -def _optional_actor_field(actor: dict[str, Any], field: str, max_chars: int) -> str: - value = actor.get(field) - if value is None: - return "" - if not isinstance(value, str): - raise HostedRoomError(f"actor.{field} must be a string") - value = value.strip() - if len(value) > max_chars: - raise HostedRoomError(f"actor.{field} is too long") - return value - - -def _validate_actor(value: Any, *, kind: str) -> tuple[dict[str, str], str]: - if not isinstance(value, dict): - raise HostedRoomError("actor must be an object") - unknown = set(value) - _ACTOR_FIELDS - if unknown: - raise HostedRoomError(f"unknown actor fields: {', '.join(sorted(unknown))}") - - actor_kind = value.get("kind") - if not isinstance(actor_kind, str) or actor_kind not in _EVENT_KINDS_BY_ACTOR: - raise HostedRoomError("invalid actor.kind") - if kind not in _EVENT_KINDS_BY_ACTOR[actor_kind]: - raise HostedRoomError(f"actor kind '{actor_kind}' cannot append '{kind}'") - - actor_id = _validate_identifier( - value.get("id"), - label="actor.id", - max_chars=MAX_ACTOR_ID_CHARS, - ) - actor = {"kind": actor_kind, "id": actor_id} - for field, max_chars in ( - ("display_name", MAX_ACTOR_LABEL_CHARS), - ("profile", MAX_ACTOR_ID_CHARS), - ("connection_id", MAX_ACTOR_ID_CHARS), - ): - field_value = _optional_actor_field(value, field, max_chars) - if field_value: - actor[field] = field_value - encoded = _canonical_json( - actor, - label="actor", - max_bytes=4 * 1024, - ) - return actor, encoded - - -def _initialize_schema(conn: sqlite3.Connection) -> None: - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_rooms ( - room_id TEXT PRIMARY KEY, - name TEXT NOT NULL, - members_json TEXT NOT NULL, - authority_gateway_id TEXT NOT NULL, - authority_epoch INTEGER NOT NULL DEFAULT 1 CHECK (authority_epoch >= 1), - next_seq INTEGER NOT NULL DEFAULT 1 CHECK (next_seq >= 1), - event_bytes INTEGER NOT NULL DEFAULT 0 CHECK (event_bytes >= 0), - revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - disbanded_at REAL - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_events ( - room_id TEXT NOT NULL, - seq INTEGER NOT NULL CHECK (seq >= 1), - event_id TEXT NOT NULL, - kind TEXT NOT NULL, - actor_json TEXT NOT NULL, - authority_epoch INTEGER CHECK (authority_epoch IS NULL OR authority_epoch >= 1), - payload_json TEXT NOT NULL, - created_at REAL NOT NULL, - PRIMARY KEY (room_id, seq), - UNIQUE (room_id, event_id), - FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id) - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_retired_ids ( - room_id TEXT PRIMARY KEY, - retired_at REAL NOT NULL - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_links ( - room_id TEXT NOT NULL, - member_id TEXT NOT NULL, - target_url TEXT NOT NULL, - target_profile TEXT NOT NULL, - grant TEXT NOT NULL, - catalog_json TEXT NOT NULL, - cancellation_scope_id TEXT NOT NULL, - trace_id TEXT NOT NULL, - transport_security TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'ready', - updated_at REAL NOT NULL, - PRIMARY KEY (room_id, member_id) - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_remote_runs ( - room_id TEXT NOT NULL, - home_install_id TEXT NOT NULL, - authority_gateway_id TEXT NOT NULL, - authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), - member_id TEXT NOT NULL, - task_id TEXT NOT NULL, - execution_generation INTEGER NOT NULL CHECK (execution_generation >= 1), - target_install_id TEXT NOT NULL, - target_profile TEXT NOT NULL, - run_id TEXT NOT NULL, - session_id TEXT NOT NULL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY ( - room_id, home_install_id, authority_gateway_id, authority_epoch, - member_id, target_install_id, target_profile, task_id, - execution_generation - ) - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_revoked_grants ( - scope_key TEXT PRIMARY KEY, - expires_at REAL NOT NULL, - revoked_before REAL NOT NULL - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_peer_reservations ( - room_id TEXT NOT NULL, - member_id TEXT NOT NULL, - target_profile TEXT NOT NULL, - authority_gateway_id TEXT NOT NULL, - authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), - expires_at REAL NOT NULL, - revoked_at REAL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY (room_id, member_id, target_profile) - )""" - ) - room_columns = {row[1] for row in conn.execute("PRAGMA table_info(hosted_rooms)")} - if "authority_gateway_id" not in room_columns: - conn.execute( - "ALTER TABLE hosted_rooms " - "ADD COLUMN authority_gateway_id TEXT NOT NULL DEFAULT 'legacy'" - ) - if "authority_epoch" not in room_columns: - conn.execute( - "ALTER TABLE hosted_rooms " - "ADD COLUMN authority_epoch INTEGER NOT NULL DEFAULT 1" - ) - backfill_event_bytes = "event_bytes" not in room_columns - if backfill_event_bytes: - conn.execute( - "ALTER TABLE hosted_rooms ADD COLUMN event_bytes INTEGER NOT NULL DEFAULT 0" - ) - - event_columns = { - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_events)") - } - if "actor_json" not in event_columns: - # Draft builds before the actor contract carried no identity. Preserve - # their inert replay rows explicitly as legacy system events rather - # than guessing a user or Bot author. - legacy_actor = _canonical_json( - {"kind": "system", "id": "legacy"}, - label="actor", - max_bytes=4 * 1024, - ) - escaped_actor = legacy_actor.replace("'", "''") - conn.execute( - "ALTER TABLE hosted_room_events " - f"ADD COLUMN actor_json TEXT NOT NULL DEFAULT '{escaped_actor}'" - ) - if "authority_epoch" not in event_columns: - conn.execute( - "ALTER TABLE hosted_room_events ADD COLUMN authority_epoch INTEGER" - ) - if backfill_event_bytes: - conn.execute( - """UPDATE hosted_rooms - SET event_bytes=COALESCE(( - SELECT SUM( - length(CAST(event_id AS BLOB)) + - length(CAST(kind AS BLOB)) + - length(CAST(actor_json AS BLOB)) + - length(CAST(payload_json AS BLOB)) - ) - FROM hosted_room_events - WHERE hosted_room_events.room_id=hosted_rooms.room_id - ), 0)""" - ) - # Old schemas kept the final identity tombstone in hosted_rooms itself. - # Copy those identities before bounded history pruning can remove their - # heavier room/event payloads. This compact registry is intentionally - # permanent: a stale coordinate must never name a different Group Chat. - conn.execute( - """INSERT OR IGNORE INTO hosted_room_retired_ids (room_id, retired_at) - SELECT room_id, disbanded_at FROM hosted_rooms - WHERE disbanded_at IS NOT NULL""" - ) - _migrate_remote_run_schema(conn) - conn.execute( - """CREATE INDEX IF NOT EXISTS idx_hosted_room_events_cursor - ON hosted_room_events(room_id, seq)""" - ) - if not _schema_is_current(conn): - raise HostedRoomError("hosted room schema migration did not complete") - - -def _schema_is_current(conn: sqlite3.Connection) -> bool: - room_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_rooms)") - ) - event_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_events)") - ) - retired_room_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_retired_ids)") - ) - link_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_links)") - ) - remote_run_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_remote_runs)") - ) - revoked_grant_columns = frozenset( - row[1] - for row in conn.execute("PRAGMA table_info(hosted_room_revoked_grants)") - ) - peer_reservation_columns = frozenset( - row[1] - for row in conn.execute("PRAGMA table_info(hosted_room_peer_reservations)") - ) - if not _ROOM_SCHEMA_COLUMNS.issubset(room_columns): - return False - if not _EVENT_SCHEMA_COLUMNS.issubset(event_columns): - return False - if not _RETIRED_ROOM_SCHEMA_COLUMNS.issubset(retired_room_columns): - return False - if not _LINK_SCHEMA_COLUMNS.issubset(link_columns): - return False - if not _REMOTE_RUN_SCHEMA_COLUMNS.issubset(remote_run_columns): - return False - if ( - _primary_key_columns(conn, "hosted_room_remote_runs") - != _REMOTE_RUN_IDENTITY_COLUMNS - ): - return False - if not _REVOKED_GRANT_SCHEMA_COLUMNS.issubset(revoked_grant_columns): - return False - if not _PEER_RESERVATION_SCHEMA_COLUMNS.issubset(peer_reservation_columns): - return False - index = conn.execute( - """SELECT 1 FROM sqlite_master - WHERE type='index' AND name='idx_hosted_room_events_cursor'""" - ).fetchone() - return index is not None - - -def list_room_link_records(db_path: Path | str) -> list[dict[str, Any]]: - """Return private RoomLink records without logging or formatting grants.""" - with _transaction(db_path) as conn: - rows = conn.execute( - """SELECT room_id, member_id, target_url, target_profile, grant, - catalog_json, cancellation_scope_id, trace_id, - transport_security, status, updated_at - FROM hosted_room_links - ORDER BY room_id, member_id""" - ).fetchall() - return [dict(row) for row in rows] - - -def upsert_room_link_record( - db_path: Path | str, - *, - record: Mapping[str, Any], - max_links: int, -) -> None: - """Atomically insert or replace one private RoomLink record.""" - with _transaction(db_path, immediate=True) as conn: - existing = conn.execute( - "SELECT 1 FROM hosted_room_links WHERE room_id=? AND member_id=?", - (record["room_id"], record["member_id"]), - ).fetchone() - if existing is None: - count = int( - conn.execute("SELECT COUNT(*) FROM hosted_room_links").fetchone()[0] - ) - if count >= max_links: - raise HostedRoomError("too many stored room links") - conn.execute( - """INSERT INTO hosted_room_links( - room_id, member_id, target_url, target_profile, grant, - catalog_json, cancellation_scope_id, trace_id, - transport_security, status, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(room_id, member_id) DO UPDATE SET - target_url=excluded.target_url, - target_profile=excluded.target_profile, - grant=excluded.grant, - catalog_json=excluded.catalog_json, - cancellation_scope_id=excluded.cancellation_scope_id, - trace_id=excluded.trace_id, - transport_security=excluded.transport_security, - status=excluded.status, - updated_at=excluded.updated_at""", - ( - record["room_id"], - record["member_id"], - record["target_url"], - record["target_profile"], - record["grant"], - record["catalog_json"], - record["cancellation_scope_id"], - record["trace_id"], - record["transport_security"], - record["status"], - record["updated_at"], - ), - ) - - -def update_room_link_status( - db_path: Path | str, - *, - room_id: str, - member_id: str, - status: str, - now: float | None = None, -) -> bool: - """Persist a non-secret route health classification.""" - with _transaction(db_path, immediate=True) as conn: - cursor = conn.execute( - """UPDATE hosted_room_links SET status=?, updated_at=? - WHERE room_id=? AND member_id=?""", - ( - status, - float(now if now is not None else time.time()), - room_id, - member_id, - ), - ) - return cursor.rowcount == 1 - - -def delete_room_link_records(db_path: Path | str, *, room_id: str) -> int: - """Delete persisted peer routes after their target grants are revoked.""" - with _transaction(db_path, immediate=True) as conn: - cursor = conn.execute( - "DELETE FROM hosted_room_links WHERE room_id=?", - (room_id,), - ) - return cursor.rowcount - - -def _room_grant_scope_key(claims: Mapping[str, Any]) -> str: - """Return a stable non-secret key for one room/home/target/profile scope.""" - import hashlib - - fields = { - key: str(claims.get(key) or "") - for key in ( - "room_id", - "home_install_id", - "authority_gateway_id", - "authority_epoch", - "member_id", - "target_install_id", - "target_profile", - ) - } - if not all(fields.values()): - raise HostedRoomError("room grant scope is incomplete") - return hashlib.sha256( - json.dumps(fields, sort_keys=True, separators=(",", ":")).encode("utf-8") - ).hexdigest() - - -def revoke_room_grant_scope( - db_path: Path | str, - *, - claims: Mapping[str, Any], - expires_at: float, - now: float | None = None, -) -> None: - """Revoke every grant issued at or before now for one exact room scope.""" - scope_key = _room_grant_scope_key(claims) - timestamp = float(now if now is not None else time.time()) - expiry = float(expires_at) - if expiry <= timestamp: - return - with _transaction(db_path, immediate=True) as conn: - conn.execute( - "DELETE FROM hosted_room_revoked_grants WHERE expires_at<=?", - (timestamp,), - ) - conn.execute( - """INSERT INTO hosted_room_revoked_grants( - scope_key, expires_at, revoked_before - ) VALUES (?, ?, ?) - ON CONFLICT(scope_key) DO UPDATE SET - expires_at=MAX(hosted_room_revoked_grants.expires_at, - excluded.expires_at), - revoked_before=MAX(hosted_room_revoked_grants.revoked_before, - excluded.revoked_before)""", - (scope_key, expiry, timestamp), - ) - conn.execute( - """UPDATE hosted_room_peer_reservations - SET revoked_at=?, updated_at=? - WHERE room_id=? AND member_id=? AND target_profile=? - AND authority_gateway_id=? AND authority_epoch=?""", - ( - timestamp, - timestamp, - str(claims.get("room_id") or ""), - str(claims.get("member_id") or ""), - str(claims.get("target_profile") or ""), - str(claims.get("authority_gateway_id") or ""), - int(claims.get("authority_epoch") or 0), - ), - ) - - -def reserve_peer_room( - db_path: Path | str, - *, - claims: Mapping[str, Any], - expires_at: float, - now: float | None = None, -) -> None: - """Fence direct Desktop prompts before the first peer run is admitted.""" - - timestamp = float(now if now is not None else time.time()) - expiry = float(expires_at) - if expiry <= timestamp: - raise HostedRoomError("peer room reservation must expire in the future") - values = ( - _validate_identifier( - claims.get("room_id"), label="room_id", max_chars=MAX_ROOM_ID_CHARS - ), - _validate_identifier( - claims.get("member_id"), label="member_id", max_chars=MAX_ACTOR_ID_CHARS - ), - _validate_identifier( - claims.get("target_profile"), - label="target_profile", - max_chars=MAX_ACTOR_ID_CHARS, - ), - _validate_identifier( - claims.get("authority_gateway_id"), - label="authority_gateway_id", - max_chars=MAX_ACTOR_ID_CHARS, - ), - int(claims.get("authority_epoch") or 0), - ) - if values[4] < 1: - raise HostedRoomError("authority_epoch must be positive") - with _transaction(db_path, immediate=True) as conn: - conn.execute( - "DELETE FROM hosted_room_peer_reservations WHERE expires_at<=?", - (timestamp,), - ) - authority_rows = conn.execute( - """SELECT authority_gateway_id, authority_epoch - FROM hosted_room_peer_reservations - WHERE room_id=? AND target_profile=? - AND expires_at>? AND revoked_at IS NULL""", - (values[0], values[2], timestamp), - ).fetchall() - if any( - int(row["authority_epoch"]) > values[4] - or ( - int(row["authority_epoch"]) == values[4] - and str(row["authority_gateway_id"]) != values[3] - ) - for row in authority_rows - ): - raise AuthorityConflictError("peer room reservation authority changed") - conn.execute( - """UPDATE hosted_room_peer_reservations - SET revoked_at=?, updated_at=? - WHERE room_id=? AND target_profile=? - AND authority_epoch values[4] - or ( - int(existing["authority_epoch"]) == values[4] - and str(existing["authority_gateway_id"]) != values[3] - ) - ): - raise AuthorityConflictError("peer room reservation authority changed") - conn.execute( - """INSERT INTO hosted_room_peer_reservations( - room_id, member_id, target_profile, authority_gateway_id, - authority_epoch, expires_at, revoked_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?) - ON CONFLICT(room_id, member_id, target_profile) DO UPDATE SET - authority_gateway_id=excluded.authority_gateway_id, - authority_epoch=excluded.authority_epoch, - expires_at=MAX(hosted_room_peer_reservations.expires_at, - excluded.expires_at), - revoked_at=NULL, - updated_at=excluded.updated_at""", - (*values, expiry, timestamp, timestamp), - ) - - -def peer_room_is_reserved( - db_path: Path | str, - *, - room_id: str, - target_profile: str, - now: float | None = None, -) -> bool: - """Return whether a live target-side RoomLink reservation fences Desktop.""" - - timestamp = float(now if now is not None else time.time()) - with _transaction(db_path) as conn: - row = conn.execute( - """SELECT 1 FROM hosted_room_peer_reservations - WHERE room_id=? AND target_profile=? - AND expires_at>? AND revoked_at IS NULL - LIMIT 1""", - ( - _validate_identifier( - room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS - ), - _validate_identifier( - target_profile, - label="target_profile", - max_chars=MAX_ACTOR_ID_CHARS, - ), - timestamp, - ), - ).fetchone() - return row is not None - - -def peer_room_grant_is_current( - db_path: Path | str, - *, - claims: Mapping[str, Any], - now: float | None = None, -) -> bool: - """Require a grant to match the target's current live reservation.""" - - timestamp = float(now if now is not None else time.time()) - room_id = _validate_identifier( - claims.get("room_id"), label="room_id", max_chars=MAX_ROOM_ID_CHARS - ) - member_id = _validate_identifier( - claims.get("member_id"), label="member_id", max_chars=MAX_ACTOR_ID_CHARS - ) - target_profile = _validate_identifier( - claims.get("target_profile"), - label="target_profile", - max_chars=MAX_ACTOR_ID_CHARS, - ) - authority_gateway_id = _validate_identifier( - claims.get("authority_gateway_id"), - label="authority_gateway_id", - max_chars=MAX_ACTOR_ID_CHARS, - ) - authority_epoch = int(claims.get("authority_epoch") or 0) - if authority_epoch < 1: - raise HostedRoomError("authority_epoch must be positive") - with _transaction(db_path) as conn: - row = conn.execute( - """SELECT 1 FROM hosted_room_peer_reservations - WHERE room_id=? AND member_id=? AND target_profile=? - AND authority_gateway_id=? AND authority_epoch=? - AND expires_at>? AND revoked_at IS NULL - LIMIT 1""", - ( - room_id, - member_id, - target_profile, - authority_gateway_id, - authority_epoch, - timestamp, - ), - ).fetchone() - return row is not None - - -def room_grant_is_revoked( - db_path: Path | str, - *, - claims: Mapping[str, Any], - now: float | None = None, -) -> bool: - """Return whether a grant predates its exact scope's revocation fence.""" - timestamp = float(now if now is not None else time.time()) - scope_key = _room_grant_scope_key(claims) - issued_at = float(claims.get("issued_at") or 0) - with _transaction(db_path) as conn: - row = conn.execute( - """SELECT revoked_before FROM hosted_room_revoked_grants - WHERE scope_key=? AND expires_at>?""", - (scope_key, timestamp), - ).fetchone() - return row is not None and issued_at <= float(row["revoked_before"]) - - -def _remote_run_identity(record: Mapping[str, Any]) -> tuple[Any, ...]: - return tuple(record[column] for column in _REMOTE_RUN_IDENTITY_COLUMNS) - - -def upsert_remote_run_receipt( - db_path: Path | str, - *, - record: Mapping[str, Any], - now: float | None = None, -) -> None: - """Durably bind one logical peer task attempt to its remote run handle.""" - timestamp = float(now if now is not None else time.time()) - identity = _remote_run_identity(record) - with _transaction(db_path, immediate=True) as conn: - existing = conn.execute( - """SELECT * FROM hosted_room_remote_runs - WHERE room_id=? AND home_install_id=? - AND authority_gateway_id=? AND authority_epoch=? - AND member_id=? AND target_install_id=? - AND target_profile=? AND task_id=? - AND execution_generation=?""", - identity, - ).fetchone() - immutable = (*identity, record["run_id"], record["session_id"]) - if existing is not None: - stored = (*_remote_run_identity(existing), existing["run_id"], existing["session_id"]) - if stored != immutable: - raise HostedRoomError( - "remote run receipt conflicts with its logical task" - ) - conn.execute( - """UPDATE hosted_room_remote_runs SET updated_at=? - WHERE room_id=? AND home_install_id=? - AND authority_gateway_id=? AND authority_epoch=? - AND member_id=? AND target_install_id=? - AND target_profile=? AND task_id=? - AND execution_generation=?""", - (timestamp, *identity), - ) - return - conn.execute( - """INSERT INTO hosted_room_remote_runs( - room_id, home_install_id, authority_gateway_id, - authority_epoch, member_id, target_install_id, - target_profile, task_id, execution_generation, run_id, - session_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - *immutable, - timestamp, - timestamp, - ), - ) - - -def list_remote_run_receipts( - db_path: Path | str, - *, - room_id: str | None = None, - target_profile: str | None = None, - session_id: str | None = None, -) -> list[dict[str, Any]]: - """Return remote run handles in durable task order.""" - conditions: list[str] = [] - values: list[Any] = [] - for column, value in ( - ("room_id", room_id), - ("target_profile", target_profile), - ("session_id", session_id), - ): - if value is not None: - conditions.append(f"{column}=?") - values.append(value) - where = f" WHERE {' AND '.join(conditions)}" if conditions else "" - with _transaction(db_path) as conn: - rows = conn.execute( - "SELECT * FROM hosted_room_remote_runs" - + where - + " ORDER BY created_at, task_id, execution_generation", - values, - ).fetchall() - return [dict(row) for row in rows] - - -def remote_run_receipt( - db_path: Path | str, - *, - record: Mapping[str, Any], -) -> dict[str, Any] | None: - """Return the exact durable remote run handle for one task attempt.""" - identity = _remote_run_identity(record) - with _transaction(db_path) as conn: - row = conn.execute( - """SELECT * FROM hosted_room_remote_runs - WHERE room_id=? AND home_install_id=? - AND authority_gateway_id=? AND authority_epoch=? - AND member_id=? AND target_install_id=? - AND target_profile=? AND task_id=? - AND execution_generation=?""", - identity, - ).fetchone() - return dict(row) if row is not None else None - - -def _connect(db_path: Path | str) -> sqlite3.Connection: - from hermes_state import apply_wal_with_fallback - - path = Path(db_path) - path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(path, timeout=10) - conn.row_factory = sqlite3.Row - try: - for attempt in range(_JOURNAL_MODE_LOCK_RETRIES): - try: - apply_wal_with_fallback(conn, db_label="state.db (hosted_rooms)") - break - except sqlite3.OperationalError as exc: - if ( - str(exc).lower() != "database is locked" - or attempt + 1 == _JOURNAL_MODE_LOCK_RETRIES - ): - raise - # SQLite's journal-mode pragma may not honor the connection's - # busy timeout while another first opener initializes the DB, - # especially on Windows. Retry only that transient lock class. - time.sleep(0.01 * (2**attempt)) - conn.execute("PRAGMA foreign_keys=ON") - if _schema_is_current(conn): - return conn - # Multiple profile gateways share this root database. Serialize every - # draft-schema transition in SQLite itself so a crash rolls back the - # whole DDL/data migration and another process can safely retry it. - conn.execute("BEGIN IMMEDIATE") - _initialize_schema(conn) - conn.commit() - except Exception: - conn.rollback() - conn.close() - raise - return conn - - -def _read_connection(db_path: Path | str) -> sqlite3.Connection: - """Open the room store without steady-state journal or migration writes.""" - - path = Path(db_path) - if not path.is_file(): - initialized = _connect(path) - initialized.close() - conn = sqlite3.connect(path, timeout=10) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys=ON") - if _schema_is_current(conn): - return conn - conn.close() - migrated = _connect(path) - migrated.close() - conn = sqlite3.connect(path, timeout=10) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys=ON") - return conn - - -@contextmanager -def _transaction( - db_path: Path | str, *, immediate: bool = False -) -> Iterator[sqlite3.Connection]: - conn = _connect(db_path) - try: - if immediate: - conn.execute("BEGIN IMMEDIATE") - yield conn - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() - - -def _raise_room_not_found(conn: sqlite3.Connection, room_id: str) -> NoReturn: - retained = conn.execute( - "SELECT 1 FROM hosted_rooms WHERE room_id=?", - (room_id,), - ).fetchone() - if retained is not None: - # A retained disband tombstone still has replayable history. The - # caller simply did not opt into reading disbanded rooms. - raise RoomNotFoundError("hosted room not found") - retired = conn.execute( - "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", - (room_id,), - ).fetchone() - if retired is not None: - raise RoomHistoryExpiredError( - "Group Chat history expired; room_id remains permanently retired" - ) - raise RoomNotFoundError("hosted room not found") - - -def _table_exists(conn: sqlite3.Connection, table: str) -> bool: - return ( - conn.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", - (table,), - ).fetchone() - is not None - ) - - -def _room_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: - room = { - "room_id": row["room_id"], - "name": row["name"], - "members": json.loads(row["members_json"]), - "authority_gateway_id": row["authority_gateway_id"], - "authority_epoch": int(row["authority_epoch"]), - "revision": int(row["revision"]), - "created_at": float(row["created_at"]), - "updated_at": float(row["updated_at"]), - "idempotent": idempotent, - } - if "disbanded_at" in row.keys() and row["disbanded_at"] is not None: - room["disbanded_at"] = float(row["disbanded_at"]) - if "next_seq" in row.keys(): - room["latest_seq"] = int(row["next_seq"]) - 1 - return room - - -def _event_storage_bytes( - *, event_id: str, kind: str, actor_json: str, payload_json: str -) -> int: - return len((event_id + kind + actor_json + payload_json).encode("utf-8")) - - -def _assert_event_capacity( - conn: sqlite3.Connection, - *, - room: sqlite3.Row, - additional_bytes: int, - allow_control: bool = False, -) -> None: - event_limit = MAX_EVENTS_PER_ROOM + ( - CONTROL_EVENT_COUNT_RESERVE if allow_control else 0 - ) - room_byte_limit = MAX_ROOM_EVENT_BYTES + ( - CONTROL_EVENT_BYTE_RESERVE if allow_control else 0 - ) - gateway_byte_limit = MAX_GATEWAY_EVENT_BYTES + ( - CONTROL_EVENT_BYTE_RESERVE if allow_control else 0 - ) - if int(room["next_seq"]) - 1 >= event_limit: - raise HostedRoomError( - "This Group Chat reached its history limit. Start a new Group Chat to continue." - ) - room_bytes = int(room["event_bytes"]) - if room_bytes + additional_bytes > room_byte_limit: - raise HostedRoomError( - "This Group Chat reached its storage limit. Start a new Group Chat to continue." - ) - gateway_bytes = int( - conn.execute( - "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" - ).fetchone()[0] - ) - if gateway_bytes + additional_bytes > gateway_byte_limit: - _prune_disbanded_rooms_locked( - conn, - now=None, - max_gateway_event_bytes=max(0, gateway_byte_limit - additional_bytes), - ) - gateway_bytes = int( - conn.execute( - "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" - ).fetchone()[0] - ) - if gateway_bytes + additional_bytes > gateway_byte_limit: - raise HostedRoomError( - "Group Chat storage is full on this host. Delete an old Group Chat and try again." - ) - - -def _table_exists(conn: sqlite3.Connection, table: str) -> bool: - return ( - conn.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", - (table,), - ).fetchone() - is not None - ) - - -def _prune_disbanded_rooms_locked( - conn: sqlite3.Connection, - *, - now: float | None, - max_gateway_event_bytes: int | None = None, -) -> int: - candidates: set[str] = set() - if now is not None: - cutoff = now - DISBANDED_ROOM_RETENTION_SECONDS - candidates.update( - str(row["room_id"]) - for row in conn.execute( - """SELECT room_id FROM hosted_rooms - WHERE disbanded_at IS NOT NULL AND disbanded_at<=?""", - (cutoff,), - ).fetchall() - ) - candidates.update( - str(row["room_id"]) - for row in conn.execute( - """SELECT room_id FROM hosted_rooms - WHERE disbanded_at IS NOT NULL - ORDER BY disbanded_at DESC, room_id ASC - LIMIT -1 OFFSET ?""", - (MAX_DISBANDED_ROOM_TOMBSTONES,), - ).fetchall() - ) - if max_gateway_event_bytes is not None: - retained_bytes = int( - conn.execute( - "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" - ).fetchone()[0] - ) - if retained_bytes > max_gateway_event_bytes: - for row in conn.execute( - """SELECT room_id, event_bytes FROM hosted_rooms - WHERE disbanded_at IS NOT NULL - ORDER BY disbanded_at ASC, room_id ASC""" - ).fetchall(): - room_id = str(row["room_id"]) - if room_id not in candidates: - candidates.add(room_id) - retained_bytes -= int(row["event_bytes"]) - if retained_bytes <= max_gateway_event_bytes: - break - if not candidates: - return 0 - - placeholders = ",".join("?" for _ in candidates) - room_ids = tuple(sorted(candidates)) - conn.execute( - f"""INSERT OR IGNORE INTO hosted_room_retired_ids (room_id, retired_at) - SELECT room_id, disbanded_at FROM hosted_rooms - WHERE room_id IN ({placeholders}) AND disbanded_at IS NOT NULL""", - room_ids, - ) - dependent_tables = ( - "hosted_room_policy_transcript_state", - "hosted_room_policy_transcript", - "hosted_room_policy_publications", - "hosted_room_policy_watermarks", - "hosted_room_policy_events", - "hosted_room_policy_threads", - "hosted_room_policy_cursors", - "hosted_room_driver_tasks", - "hosted_room_driver_leases", - "hosted_room_remote_runs", - "hosted_room_links", - "hosted_room_peer_reservations", - "hosted_room_events", - ) - for table in dependent_tables: - if _table_exists(conn, table): - conn.execute( - f"DELETE FROM {table} WHERE room_id IN ({placeholders})", - room_ids, - ) - conn.execute( - f"DELETE FROM hosted_rooms WHERE room_id IN ({placeholders})", - room_ids, - ) - return len(room_ids) - - -def prune_disbanded_rooms( - db_path: Path | str, - *, - now: float | None = None, -) -> int: - """Purge deleted Group Chat payloads while reserving their identities.""" - - timestamp = time.time() if now is None else float(now) - with _transaction(db_path, immediate=True) as conn: - return _prune_disbanded_rooms_locked(conn, now=timestamp) - - -def _event_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: - return { - "room_id": row["room_id"], - "seq": int(row["seq"]), - "event_id": row["event_id"], - "kind": row["kind"], - "actor": json.loads(row["actor_json"]), - "authority_epoch": ( - int(row["authority_epoch"]) if row["authority_epoch"] is not None else None - ), - "payload": json.loads(row["payload_json"]), - "created_at": float(row["created_at"]), - "idempotent": idempotent, - } def create_room( @@ -1436,6 +115,11 @@ def create_room( now = time.time() if now is None else float(now) with _transaction(db_path, immediate=True) as conn: + _raise_if_quarantined(conn, room_id) + if _replica_reserves_room_id_locked(conn, room_id): + raise RoomConflictError("room_id belongs to a passive replica") + if _room_id_reservation_kind_locked(conn, room_id) == "replica": + raise RoomConflictError("room_id belongs to a retired passive replica") if conn.execute( "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", (room_id,), @@ -1604,12 +288,16 @@ def list_rooms( conn = _read_connection(db_path) try: rows = conn.execute( - """SELECT room_id, name, members_json, authority_gateway_id, - authority_epoch, next_seq, revision, created_at, updated_at, - disbanded_at - FROM hosted_rooms - WHERE disbanded_at IS NULL OR ? - ORDER BY updated_at DESC, room_id ASC + """SELECT rooms.room_id, rooms.name, rooms.members_json, + rooms.authority_gateway_id, rooms.authority_epoch, + rooms.next_seq, rooms.revision, rooms.created_at, + rooms.updated_at, rooms.disbanded_at, + quarantine.reason AS quarantine_reason + FROM hosted_rooms AS rooms + LEFT JOIN hosted_room_quarantine AS quarantine + ON quarantine.room_id=rooms.room_id + WHERE rooms.disbanded_at IS NULL OR ? + ORDER BY rooms.updated_at DESC, rooms.room_id ASC LIMIT ? OFFSET ?""", (int(include_disbanded), limit, offset), ).fetchall() @@ -1779,6 +467,7 @@ def append_event( now = time.time() if now is None else float(now) with _transaction(db_path, immediate=True) as conn: + _raise_if_quarantined(conn, room_id) existing = conn.execute( """SELECT room_id, seq, event_id, kind, actor_json, authority_epoch, payload_json, created_at @@ -1970,6 +659,7 @@ def room_state( max_chars=MAX_ROOM_ID_CHARS, ) with _transaction(db_path) as conn: + _raise_if_quarantined(conn, room_id) row = conn.execute( """SELECT room_id, name, members_json, authority_gateway_id, authority_epoch, next_seq, revision, created_at, updated_at, @@ -2086,6 +776,7 @@ def claim_authority( ) with _transaction(db_path, immediate=True) as conn: + _raise_if_quarantined(conn, room_id) row = conn.execute( """SELECT authority_gateway_id, authority_epoch, next_seq, event_bytes FROM hosted_rooms @@ -2220,6 +911,7 @@ def disband_room( now = time.time() if now is None else float(now) with _transaction(db_path, immediate=True) as conn: + _raise_if_quarantined(conn, room_id) room = conn.execute( """SELECT authority_gateway_id, authority_epoch, next_seq, event_bytes, disbanded_at diff --git a/tests/gateway/test_hosted_room_replicas.py b/tests/gateway/test_hosted_room_replicas.py index cfe550168378d..e2021356b2c9c 100644 --- a/tests/gateway/test_hosted_room_replicas.py +++ b/tests/gateway/test_hosted_room_replicas.py @@ -1,7 +1,7 @@ -"""Tests for gateway/hosted_room_replicas.py — replica ingest, promotion, and -stale-authority demotion for hosted Group Chat rooms.""" +"""Tests for passive hosted Group Chat room replicas.""" import json +import sqlite3 import pytest @@ -73,6 +73,203 @@ def test_ingest_page_is_idempotent(tmp_path): assert again["stored_seq"] == 3 +def test_passive_replica_reserves_room_id_against_local_create(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + db = _replica_db(tmp_path) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + with pytest.raises(rooms.RoomConflictError, match="passive replica"): + rooms.create_room( + db, + room_id="room-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + + +def test_database_guard_blocks_an_old_process_promoting_a_replica(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + db = _replica_db(tmp_path) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + with sqlite3.connect(db) as conn, pytest.raises( + sqlite3.IntegrityError, match="already reserved" + ): + conn.execute( + """INSERT INTO hosted_rooms + (room_id, name, members_json, authority_gateway_id, + authority_epoch, next_seq, event_bytes, revision, + created_at, updated_at, disbanded_at) + VALUES ('room-1', 'Field Room', ?, ?, 2, 4, 0, 1, 2, 2, NULL)""", + (json.dumps(MEMBERS, separators=(",", ":")), AUTH_B), + ) + + +def test_disbanded_replica_room_id_cannot_be_recreated(tmp_path): + authority_db = _authority_db(tmp_path) + _seed_room(authority_db, n_events=1) + rooms.disband_room( + authority_db, + room_id="room-1", + expected_gateway_id=AUTH_A, + expected_epoch=1, + ) + page = rooms.read_events( + authority_db, room_id="room-1", include_disbanded=True + ) + db = _replica_db(tmp_path) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + with pytest.raises(rooms.RoomConflictError, match="passive replica"): + rooms.create_room( + db, + room_id="room-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + + +def test_replica_ingest_rejects_existing_authoritative_room(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + db = _replica_db(tmp_path) + rooms.create_room( + db, + room_id="room-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + with pytest.raises(replicas.ReplicaError, match="locally authoritative"): + replicas.ingest_page( + db, + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) + + +@pytest.mark.parametrize( + ("kind", "payload", "reason"), + [ + ( + "authority.claimed", + { + "authority_gateway_id": AUTH_B, + "authority_epoch": 1, + "previous_gateway_id": AUTH_A, + "promoted_from_replica": True, + }, + "unsafe_replica_promotion", + ), + ( + "authority.lost", + { + "authority_gateway_id": AUTH_B, + "authority_epoch": 1, + "previous_gateway_id": AUTH_A, + }, + "unsafe_authority_demotion", + ), + ], +) +def test_migration_quarantines_unsafe_takeover_lineage( + tmp_path, kind, payload, reason +): + db = _replica_db(tmp_path) + rooms.create_room( + db, + room_id="room-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + rooms.append_event( + db, + room_id="room-1", + event_id=f"unsafe-{kind}", + kind=kind, + actor={"kind": "system", "id": "authority-control"}, + payload=payload, + authority_gateway_id=AUTH_B, + authority_epoch=1, + ) + with sqlite3.connect(db) as conn: + conn.execute("DROP TABLE hosted_room_quarantine") + + with pytest.raises(rooms.RoomQuarantinedError, match="read-only"): + rooms.room_state(db, room_id="room-1") + listed = rooms.list_rooms(db) + assert listed[0]["safety_status"] == "authority_quarantined" + assert listed[0]["safety_reason"] == reason + with pytest.raises(rooms.RoomQuarantinedError): + rooms.append_event( + db, + room_id="room-1", + event_id="blocked", + kind="message.user", + actor=USER, + payload={"text": "must not commit"}, + authority_gateway_id=AUTH_B, + authority_epoch=1, + ) + + +def test_database_guard_quarantines_a_late_old_process_demotion(tmp_path): + db = _replica_db(tmp_path) + rooms.create_room( + db, + room_id="room-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_A, + ) + actor = json.dumps( + {"kind": "system", "id": "authority-control"}, + separators=(",", ":"), + sort_keys=True, + ) + payload = json.dumps( + { + "previous_gateway_id": AUTH_A, + "authority_gateway_id": AUTH_B, + "authority_epoch": 2, + }, + separators=(",", ":"), + sort_keys=True, + ) + with sqlite3.connect(db) as conn: + conn.execute( + """INSERT INTO hosted_room_events + (room_id, seq, event_id, kind, actor_json, authority_epoch, + payload_json, created_at) + VALUES ('room-1', 1, 'old-demotion', 'authority.lost', ?, 2, ?, 2)""", + (actor, payload), + ) + conn.execute( + """UPDATE hosted_rooms + SET authority_gateway_id=?, authority_epoch=2, next_seq=2 + WHERE room_id='room-1'""", + (AUTH_B,), + ) + with pytest.raises(sqlite3.IntegrityError, match="quarantined"): + conn.execute( + """INSERT INTO hosted_room_events + (room_id, seq, event_id, kind, actor_json, authority_epoch, + payload_json, created_at) + VALUES ('room-1', 2, 'late-write', 'message.user', ?, 2, + '{"text":"unsafe"}', 3)""", + (json.dumps(USER, separators=(",", ":"), sort_keys=True),), + ) + with pytest.raises(rooms.RoomQuarantinedError): + rooms.room_state(db, room_id="room-1") + + def test_ingest_rejects_sequence_gap(tmp_path): adb = _authority_db(tmp_path) _seed_room(adb, n_events=5) @@ -88,30 +285,28 @@ def test_ingest_rejects_sequence_gap(tmp_path): ) -def test_ingest_rejects_epoch_regression(tmp_path): +def test_ingest_rejects_conflicting_overlap(tmp_path): page = _seed_room(_authority_db(tmp_path)) rdb = _replica_db(tmp_path) - newer = json.loads(json.dumps(page)) - newer["authority"]["epoch"] = 3 replicas.ingest_page( - rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=newer + rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page ) - stale = json.loads(json.dumps(page)) - stale["authority"]["epoch"] = 2 - with pytest.raises(replicas.ReplicaEpochRegressionError): + conflicting = json.loads(json.dumps(page)) + conflicting["events"][0]["payload"]["text"] = "rewritten" + with pytest.raises(replicas.ReplicaError, match="conflicts"): replicas.ingest_page( rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, - page=stale, + page=conflicting, ) -def test_ingest_requires_authority_stamp(tmp_path): +def test_ingest_rejects_duplicate_event_ids_in_one_page(tmp_path): page = _seed_room(_authority_db(tmp_path)) - page.pop("authority") - with pytest.raises(replicas.ReplicaError): + page["events"][1]["event_id"] = page["events"][0]["event_id"] + with pytest.raises(replicas.ReplicaError, match="repeats an event_id"): replicas.ingest_page( _replica_db(tmp_path), room_id="room-1", @@ -121,176 +316,616 @@ def test_ingest_requires_authority_stamp(tmp_path): ) -def test_promote_replica_continues_room_at_next_epoch(tmp_path, monkeypatch): +def test_ingest_rejects_same_epoch_gateway_substitution(tmp_path): page = _seed_room(_authority_db(tmp_path)) rdb = _replica_db(tmp_path) replicas.ingest_page( rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page ) - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_B) + substituted = json.loads(json.dumps(page)) + substituted["authority"]["gateway_id"] = AUTH_B + with pytest.raises( + replicas.ReplicaLineageUnverifiedError, match="authority" + ) as raised: + replicas.ingest_page( + rdb, + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=substituted, + ) + assert raised.value.reason == "replica_lineage_unverified" - promoted = replicas.promote_replica(rdb, room_id="room-1") - assert promoted["authority_gateway_id"] == AUTH_B - assert promoted["authority_epoch"] == 2 - assert promoted["previous_gateway_id"] == AUTH_A - assert promoted["claim_seq"] == 4 - # The room is now locally authoritative with the full history + claim. - replay = rooms.read_events(rdb, room_id="room-1", since_seq=0, limit=100) - assert [e["seq"] for e in replay["events"]] == [1, 2, 3, 4] - claim = replay["events"][-1] - assert claim["kind"] == "authority.claimed" - assert claim["payload"]["previous_gateway_id"] == AUTH_A - assert claim["payload"]["authority_epoch"] == 2 - assert replay["authority"] == {"gateway_id": AUTH_B, "epoch": 2} +def test_ingest_rejects_epoch_jump_without_claim(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + jumped = json.loads(json.dumps(page)) + jumped["authority"] = {"gateway_id": AUTH_B, "epoch": 3} + with pytest.raises(replicas.ReplicaError, match="lineage"): + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=jumped, + ) - # New work continues under the new epoch. - rooms.append_event( - rdb, + +def test_fresh_replica_reports_unverified_later_epoch_lineage(tmp_path): + authority_db = _authority_db(tmp_path) + _seed_room(authority_db, n_events=1) + rooms.claim_authority( + authority_db, room_id="room-1", - event_id="post-takeover", - kind="message.user", - actor=USER, - payload={"text": "continuing"}, - authority_gateway_id=AUTH_B, - authority_epoch=2, + expected_gateway_id=AUTH_A, + expected_epoch=1, + new_gateway_id=AUTH_B, + event_id="claim-b", ) + page = rooms.read_events(authority_db, room_id="room-1", limit=100) - # The old authority's identity/epoch is fenced out. - with pytest.raises(rooms.HostedRoomError): - rooms.append_event( + with pytest.raises( + replicas.ReplicaLineageUnverifiedError, match="first authority epoch" + ) as raised: + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) + assert raised.value.reason == "replica_lineage_unverified" + + +def test_ingest_rejects_latest_seq_regression(tmp_path): + page = _seed_room(_authority_db(tmp_path), n_events=4) + rdb = _replica_db(tmp_path) + replicas.ingest_page( + rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + stale = json.loads(json.dumps(page)) + stale["latest_seq"] = 2 + stale["cursor"] = 2 + stale["events"] = stale["events"][:2] + stale["has_more"] = False + with pytest.raises(replicas.ReplicaError, match="regress"): + replicas.ingest_page( rdb, room_id="room-1", - event_id="stale-write", - kind="message.user", - actor=USER, - payload={"text": "stale"}, - authority_gateway_id=AUTH_A, - authority_epoch=1, + room_name="Field Room", + members=MEMBERS, + page=stale, + ) + + +def test_ingest_rejects_inconsistent_page_cursor(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + page["cursor"] -= 1 + with pytest.raises(replicas.ReplicaError, match="cursor"): + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, ) - # Replica bookkeeping is consumed by promotion. + +def test_ingest_rejects_non_verbatim_oversized_page(tmp_path): + page = _seed_room(_authority_db(tmp_path), n_events=1) + template = page["events"][0] + page["events"] = [ + { + **template, + "seq": index, + "event_id": f"event-{index}", + } + for index in range(1, rooms.MAX_LOG_LIMIT + 2) + ] + page["cursor"] = len(page["events"]) + page["latest_seq"] = len(page["events"]) + with pytest.raises(replicas.ReplicaError, match="cannot exceed"): + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) + + +def test_ingest_requires_authority_stamp(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + page.pop("authority") with pytest.raises(replicas.ReplicaError): - replicas.replica_state(rdb, room_id="room-1") + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) -def test_promote_refuses_when_room_exists_locally(tmp_path, monkeypatch): - db = _authority_db(tmp_path) - page = _seed_room(db) - # Same DB also holds a replica row for the same id — conflict must win. - replicas.ingest_page( - db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page +def test_partial_replica_remains_passive_and_reports_coverage(tmp_path): + adb = _authority_db(tmp_path) + _seed_room(adb, n_events=5) + partial = rooms.read_events(adb, room_id="room-1", since_seq=0, limit=2) + rdb = _replica_db(tmp_path) + result = replicas.ingest_page( + rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=partial ) - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_B) - with pytest.raises(rooms.RoomConflictError): - replicas.promote_replica(db, room_id="room-1") + assert result["caught_up"] is False + state = replicas.replica_state(rdb, room_id="room-1") + assert state["last_seq"] == 2 + assert state["latest_seq"] == 5 + assert not hasattr(replicas, "promote_replica") + assert not hasattr(replicas, "demote_room") -def test_promote_refuses_when_already_authority(tmp_path, monkeypatch): - page = _seed_room(_authority_db(tmp_path)) +def test_disbanded_room_replica_keeps_terminal_state(tmp_path): + adb = _authority_db(tmp_path) + _seed_room(adb, n_events=1) + rooms.disband_room( + adb, + room_id="room-1", + expected_gateway_id=AUTH_A, + expected_epoch=1, + ) + page = rooms.read_events( + adb, + room_id="room-1", + since_seq=0, + limit=100, + include_disbanded=True, + ) rdb = _replica_db(tmp_path) replicas.ingest_page( rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page ) - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_A) - with pytest.raises(replicas.ReplicaError): - replicas.promote_replica(rdb, room_id="room-1") + assert replicas.replica_state(rdb, room_id="room-1")["disbanded_at"] is not None -def test_demote_fences_stale_local_authority(tmp_path, monkeypatch): - adb = _authority_db(tmp_path) - _seed_room(adb) - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_A) +def test_ingest_rejects_terminal_event_before_source_history_is_complete(tmp_path): + page = _terminal_page(tmp_path, room_id="room-1") + page["latest_seq"] += 1 + page["has_more"] = True + with pytest.raises(replicas.ReplicaError, match="complete the source history"): + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) - result = replicas.demote_room( - adb, room_id="room-1", observed_gateway_id=AUTH_B, observed_epoch=2 - ) - assert result["idempotent"] is False - assert result["authority_gateway_id"] == AUTH_B - assert result["authority_epoch"] == 2 - replay = rooms.read_events(adb, room_id="room-1", since_seq=0, limit=100) - lost = replay["events"][-1] - assert lost["kind"] == "authority.lost" - assert lost["payload"]["authority_gateway_id"] == AUTH_B - assert replay["authority"] == {"gateway_id": AUTH_B, "epoch": 2} +def test_replica_storage_shares_the_gateway_event_budget(tmp_path, monkeypatch): + page = _seed_room(_authority_db(tmp_path)) + monkeypatch.setattr(replicas, "MAX_REPLICA_EVENT_BYTES", 0) + with pytest.raises(replicas.ReplicaError, match="storage exhausted"): + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) + - # Local sends at the stale identity/epoch are now rejected. - with pytest.raises(rooms.HostedRoomError): +def test_authoritative_append_also_counts_replica_bytes(tmp_path, monkeypatch): + page = _seed_room(_authority_db(tmp_path)) + db = _replica_db(tmp_path) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + rooms.create_room( + db, + room_id="local-room", + name="Local", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + with sqlite3.connect(db) as conn: + replica_bytes = int( + conn.execute( + "SELECT SUM(event_bytes) FROM hosted_room_replicas" + ).fetchone()[0] + ) + monkeypatch.setattr(rooms, "MAX_GATEWAY_EVENT_BYTES", replica_bytes) + with pytest.raises(rooms.HostedRoomError, match="storage is full"): rooms.append_event( - adb, - room_id="room-1", - event_id="after-demote", + db, + room_id="local-room", + event_id="would-overflow", kind="message.user", actor=USER, - payload={"text": "stale"}, - authority_gateway_id=AUTH_A, + payload={"text": "one byte too many"}, + authority_gateway_id=AUTH_B, authority_epoch=1, ) - # Repeating the same observation is idempotent. - again = replicas.demote_room( - adb, room_id="room-1", observed_gateway_id=AUTH_B, observed_epoch=2 + +def test_replica_budget_matches_the_authoritative_store(): + assert replicas.MAX_REPLICA_EVENT_BYTES == rooms.MAX_GATEWAY_EVENT_BYTES + + +def _terminal_page(tmp_path, *, room_id: str, now: float = 0): + authority_db = tmp_path / f"authority-{room_id}.db" + _seed_room(authority_db, n_events=1, room_id=room_id) + rooms.disband_room( + authority_db, + room_id=room_id, + expected_gateway_id=AUTH_A, + expected_epoch=1, + now=now, + ) + return rooms.read_events( + authority_db, + room_id=room_id, + include_disbanded=True, ) - assert again["idempotent"] is True -def test_demote_rejects_non_superseding_epoch(tmp_path, monkeypatch): - adb = _authority_db(tmp_path) - _seed_room(adb) - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_A) - with pytest.raises(replicas.ReplicaEpochRegressionError): - replicas.demote_room( - adb, room_id="room-1", observed_gateway_id=AUTH_B, observed_epoch=1 +def test_aged_terminal_replicas_free_room_slots_but_keep_ids( + tmp_path, monkeypatch +): + db = _replica_db(tmp_path) + monkeypatch.setattr(replicas, "MAX_REPLICA_ROOMS", 2) + monkeypatch.setattr(rooms, "DISBANDED_REPLICA_RETENTION_SECONDS", 1) + for room_id in ("old-1", "old-2"): + replicas.ingest_page( + db, + room_id=room_id, + room_name="Field Room", + members=MEMBERS, + page=_terminal_page(tmp_path, room_id=room_id), + now=0, ) + fresh_page = _seed_room( + tmp_path / "authority-fresh.db", room_id="fresh", n_events=1 + ) + replicas.ingest_page( + db, + room_id="fresh", + room_name="Field Room", + members=MEMBERS, + page=fresh_page, + now=2, + ) + with pytest.raises( + replicas.ReplicaHistoryExpiredError, match="history expired" + ): + replicas.replica_state(db, room_id="old-1") + with pytest.raises(rooms.RoomConflictError, match="retired passive replica"): + rooms.create_room( + db, + room_id="old-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) -def test_full_failover_round_trip(tmp_path, monkeypatch): - """Authority A hosts, replica B follows, A dies, B promotes, A returns - and is fenced + demoted; the room's history survives intact throughout.""" - adb = _authority_db(tmp_path) - rdb = _replica_db(tmp_path) - page = _seed_room(adb, n_events=4) + +def test_fresh_terminal_replicas_yield_slots_under_count_pressure( + tmp_path, monkeypatch +): + db = _replica_db(tmp_path) + monkeypatch.setattr(replicas, "MAX_REPLICA_ROOMS", 2) + for room_id in ("recent-1", "recent-2"): + replicas.ingest_page( + db, + room_id=room_id, + room_name="Field Room", + members=MEMBERS, + page=_terminal_page(tmp_path, room_id=room_id, now=10), + now=10, + ) + fresh_page = _seed_room( + tmp_path / "authority-current.db", room_id="current", n_events=1 + ) replicas.ingest_page( - rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + db, + room_id="current", + room_name="Field Room", + members=MEMBERS, + page=fresh_page, + now=10, ) + with pytest.raises(replicas.ReplicaHistoryExpiredError): + replicas.replica_state(db, room_id="recent-1") + with sqlite3.connect(db) as conn, pytest.raises( + sqlite3.IntegrityError, match="already reserved" + ): + conn.execute( + """INSERT INTO hosted_rooms + (room_id, name, members_json, authority_gateway_id, + authority_epoch, next_seq, event_bytes, revision, + created_at, updated_at, disbanded_at) + VALUES ('recent-1', 'Field Room', ?, ?, 2, 1, 0, 1, 3, 3, NULL)""", + (json.dumps(MEMBERS, separators=(",", ":")), AUTH_B), + ) - # A "dies"; B takes over. - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_B) - promoted = replicas.promote_replica(rdb, room_id="room-1") - rooms.append_event( - rdb, - room_id="room-1", - event_id="b-work", + +def test_authoritative_append_reclaims_terminal_replica_bytes( + tmp_path, monkeypatch +): + db = _replica_db(tmp_path) + replicas.ingest_page( + db, + room_id="old", + room_name="Field Room", + members=MEMBERS, + page=_terminal_page(tmp_path, room_id="old"), + now=0, + ) + with sqlite3.connect(db) as conn: + replica_bytes = int( + conn.execute( + "SELECT SUM(event_bytes) FROM hosted_room_replicas" + ).fetchone()[0] + ) + rooms.create_room( + db, + room_id="local-room", + name="Local", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + monkeypatch.setattr(rooms, "MAX_GATEWAY_EVENT_BYTES", replica_bytes) + event = rooms.append_event( + db, + room_id="local-room", + event_id="after-pressure", kind="message.user", actor=USER, - payload={"text": "work continues on B"}, + payload={"text": "still writable"}, authority_gateway_id=AUTH_B, - authority_epoch=promoted["authority_epoch"], + authority_epoch=1, ) + assert event["seq"] == 1 + with pytest.raises(replicas.ReplicaHistoryExpiredError): + replicas.replica_state(db, room_id="old") + with pytest.raises(rooms.RoomConflictError, match="retired passive replica"): + rooms.create_room( + db, + room_id="old", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) - # A comes back, observes B's claim, and fences itself. - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_A) - replicas.demote_room( - adb, + +def test_pre_terminal_state_replica_schema_migrates_in_place(tmp_path): + db = _replica_db(tmp_path) + with sqlite3.connect(db) as conn: + conn.execute( + """CREATE TABLE hosted_room_replicas ( + room_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + members_json TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + last_seq INTEGER NOT NULL, + latest_seq INTEGER NOT NULL, + event_bytes INTEGER NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + conn.execute( + """INSERT INTO hosted_room_replicas VALUES + ('room-1', 'Field Room', ?, ?, 1, 0, 0, 0, 1.0, 1.0)""", + (json.dumps(MEMBERS, separators=(",", ":")), AUTH_A), + ) + state = replicas.replica_state(db, room_id="room-1") + assert state["disbanded_at"] is None + + +def test_schema_migration_recovers_existing_disband_tombstone(tmp_path): + authority_db = _authority_db(tmp_path) + _seed_room(authority_db, n_events=1) + rooms.disband_room( + authority_db, room_id="room-1", - observed_gateway_id=AUTH_B, - observed_epoch=promoted["authority_epoch"], + expected_gateway_id=AUTH_A, + expected_epoch=1, + now=42, ) - with pytest.raises(rooms.HostedRoomError): - rooms.append_event( - adb, - room_id="room-1", - event_id="a-stale", - kind="message.user", - actor=USER, - payload={"text": "split brain attempt"}, + page = rooms.read_events( + authority_db, + room_id="room-1", + include_disbanded=True, + ) + db = _replica_db(tmp_path) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + with sqlite3.connect(db) as conn: + conn.execute("UPDATE hosted_room_replicas SET disbanded_at=NULL") + assert replicas.replica_state(db, room_id="room-1")["disbanded_at"] == 42 + + +def test_schema_migration_quarantines_post_tombstone_history(tmp_path): + db = _replica_db(tmp_path) + actor = json.dumps(USER, separators=(",", ":"), sort_keys=True) + system_actor = json.dumps( + {"kind": "system", "id": "room-control"}, + separators=(",", ":"), + sort_keys=True, + ) + with sqlite3.connect(db) as conn: + conn.execute( + """CREATE TABLE hosted_room_replicas ( + room_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + members_json TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + last_seq INTEGER NOT NULL, + latest_seq INTEGER NOT NULL, + event_bytes INTEGER NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + conn.execute( + """CREATE TABLE hosted_room_replica_events ( + room_id TEXT NOT NULL, + seq INTEGER NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + actor_json TEXT NOT NULL, + authority_epoch INTEGER, + payload_json TEXT NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (room_id, seq) + )""" + ) + conn.execute( + """INSERT INTO hosted_room_replicas VALUES + ('room-1', 'Field Room', ?, ?, 1, 2, 2, 0, 1.0, 2.0)""", + (json.dumps(MEMBERS, separators=(",", ":")), AUTH_A), + ) + conn.executemany( + """INSERT INTO hosted_room_replica_events VALUES + ('room-1', ?, ?, ?, ?, 1, ?, ?)""", + [ + (1, "disband", "room.disbanded", system_actor, "{}", 1.0), + (2, "later", "message.user", actor, '{"text":"later"}', 2.0), + ], + ) + state = replicas.replica_state(db, room_id="room-1") + assert state["safety_status"] == "quarantined" + assert state["safety_reason"] == "events_after_disband" + assert state["event_bytes"] > 0 + with sqlite3.connect(db) as conn: + conn.row_factory = sqlite3.Row + assert rooms._prune_disbanded_replicas_locked( # noqa: SLF001 + conn, + now=None, + max_replica_event_bytes=0, + max_replica_rooms=0, + ) == 0 + assert replicas.replica_state(db, room_id="room-1")["safety_status"] == ( + "quarantined" + ) + + +def test_each_replica_transaction_audits_late_old_process_writes(tmp_path): + db = _replica_db(tmp_path) + page = _seed_room(_authority_db(tmp_path), n_events=2) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + + with sqlite3.connect(db) as conn: + original = conn.execute( + """SELECT event_id, kind, actor_json, authority_epoch, + payload_json, created_at + FROM hosted_room_replica_events + WHERE room_id='room-1' AND seq=1""" + ).fetchone() + conn.execute( + """INSERT INTO hosted_room_replica_events( + room_id, seq, event_id, kind, actor_json, authority_epoch, + payload_json, created_at + ) VALUES ('room-1', 3, ?, ?, ?, ?, ?, ?)""", + original, + ) + conn.execute( + """UPDATE hosted_room_replicas + SET last_seq=3, latest_seq=3 + WHERE room_id='room-1'""" + ) + + state = replicas.replica_state(db, room_id="room-1") + assert state["safety_status"] == "quarantined" + assert state["safety_reason"] == "duplicate_event_id" + + +def test_late_old_process_gateway_actor_must_match_replica_authority(tmp_path): + db = _replica_db(tmp_path) + page = _seed_room(_authority_db(tmp_path), n_events=2) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + + with sqlite3.connect(db) as conn: + conn.execute( + """INSERT INTO hosted_room_replica_events( + room_id, seq, event_id, kind, actor_json, authority_epoch, + payload_json, created_at + ) VALUES ('room-1', 3, 'old-gateway-write', 'room.activity', + ?, 1, '{}', 3.0)""", + ( + json.dumps( + {"kind": "gateway", "id": AUTH_B}, + sort_keys=True, + separators=(",", ":"), + ), + ), + ) + conn.execute( + """UPDATE hosted_room_replicas + SET last_seq=3, latest_seq=3 + WHERE room_id='room-1'""" + ) + + state = replicas.replica_state(db, room_id="room-1") + assert state["safety_status"] == "quarantined" + assert state["safety_reason"] == "gateway_actor_authority_mismatch" + + +def test_late_old_process_cannot_assert_a_later_epoch_without_proof(tmp_path): + db = _replica_db(tmp_path) + page = _seed_room(_authority_db(tmp_path), n_events=2) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + + with sqlite3.connect(db) as conn: + conn.execute( + "UPDATE hosted_room_replicas SET authority_epoch=2 WHERE room_id='room-1'" + ) + conn.execute( + """UPDATE hosted_room_replica_events + SET authority_epoch=2 WHERE room_id='room-1'""" + ) + + state = replicas.replica_state(db, room_id="room-1") + assert state["safety_status"] == "quarantined" + assert state["safety_reason"] == "unverified_authority_epoch" + + +def test_sharded_store_preserves_public_limit_overrides(tmp_path, monkeypatch): + db = _replica_db(tmp_path) + rooms.create_room( + db, + room_id="room-1", + name="Room", + members=MEMBERS, + authority_gateway_id=AUTH_A, + ) + + monkeypatch.setattr(rooms, "MAX_ROOM_NAME_CHARS", 3) + with pytest.raises(rooms.HostedRoomError, match="invalid room name"): + rooms.create_room( + db, + room_id="room-2", + name="Long name", + members=MEMBERS, authority_gateway_id=AUTH_A, - authority_epoch=1, ) - # B's room holds the complete history: 4 original + claim + new work. - replay = rooms.read_events(rdb, room_id="room-1", since_seq=0, limit=100) - kinds = [e["kind"] for e in replay["events"]] - assert kinds == ["message.user"] * 4 + ["authority.claimed", "message.user"] - assert replay["authority"]["gateway_id"] == AUTH_B + monkeypatch.setattr(rooms, "MAX_EVENTS_PER_ROOM", 0) + monkeypatch.setattr(rooms, "CONTROL_EVENT_COUNT_RESERVE", 0) + monkeypatch.setattr(rooms, "CONTROL_EVENT_BYTE_RESERVE", 0) + with pytest.raises(rooms.HostedRoomError, match="history limit"): + rooms.disband_room( + db, + room_id="room-1", + expected_gateway_id=AUTH_A, + expected_epoch=1, + ) diff --git a/tests/tui_gateway/test_groups_replication_methods.py b/tests/tui_gateway/test_groups_replication_methods.py index 642a0561fac88..325b0c94e9d19 100644 --- a/tests/tui_gateway/test_groups_replication_methods.py +++ b/tests/tui_gateway/test_groups_replication_methods.py @@ -1,5 +1,4 @@ -"""Tests for the ``groups.replicate`` / ``groups.promote`` / ``groups.demote`` -JSON-RPC surface — cross-gateway room durability.""" +"""Tests for the fail-closed passive-replica JSON-RPC surface.""" from __future__ import annotations @@ -8,8 +7,6 @@ import tui_gateway.server as srv from tui_gateway import methods_groups -MEMBERS = [{"kind": "bot", "id": "planner"}] - @pytest.fixture def home(tmp_path, monkeypatch): @@ -33,157 +30,21 @@ def _error(envelope): return envelope["error"] -def _authority_page(tmp_path, gateway_id="install:" + "a" * 32, n=3): - """Build a real room + log on a SEPARATE 'remote authority' DB and return - its replay page, as a replicating client would fetch via groups.log.""" - from gateway import hosted_rooms as rooms - - db = tmp_path / "remote-authority.db" - rooms.create_room( - db, - room_id="room-1", - name="Field Room", - members=MEMBERS, - authority_gateway_id=gateway_id, - ) - for index in range(n): - rooms.append_event( - db, - room_id="room-1", - event_id=f"e{index}", - kind="message.user", - actor={"kind": "user", "id": "tek"}, - payload={"text": f"msg {index}"}, - authority_gateway_id=gateway_id, - authority_epoch=1, - ) - return rooms.read_events(db, room_id="room-1", since_seq=0, limit=100) - - -def test_capabilities_advertise_replication(home): +def test_capabilities_do_not_advertise_unverified_replication(home): result = _result(srv._methods["groups.capabilities"](1, {})) - assert "log_replication" in result["features"] - assert "authority_takeover" in result["features"] - for name in ( - "groups.replicate", - "groups.replica_state", - "groups.promote", - "groups.demote", - ): - assert name in result["methods"] - assert name in srv._LONG_HANDLERS - - -def test_replicate_then_state_roundtrip(home, tmp_path): - page = _authority_page(tmp_path) - result = _result( - srv._methods["groups.replicate"]( - 1, - { - "room_id": "room-1", - "room_name": "Field Room", - "members": MEMBERS, - "page": page, - }, - ) - ) - assert result["ingested"] == 3 - state = _result(srv._methods["groups.replica_state"](2, {"room_id": "room-1"})) - assert state["last_seq"] == 3 - assert state["authority"] == page["authority"] - - -def test_promote_requires_confirm_and_takes_over(home, tmp_path): - page = _authority_page(tmp_path) - _result( - srv._methods["groups.replicate"]( - 1, - { - "room_id": "room-1", - "room_name": "Field Room", - "members": MEMBERS, - "page": page, - }, - ) - ) - - refused = _error(srv._methods["groups.promote"](2, {"room_id": "room-1"})) - assert refused["code"] == 4118 - - promoted = _result( - srv._methods["groups.promote"](3, {"room_id": "room-1", "confirm": True}) - ) - assert promoted["authority_epoch"] == 2 - assert promoted["previous_gateway_id"] == page["authority"]["gateway_id"] - - # The room is now hosted locally with full history + claim event. - log = _result(srv._methods["groups.log"](4, {"room_id": "room-1"})) - kinds = [event["kind"] for event in log["events"]] - assert kinds == ["message.user"] * 3 + ["authority.claimed"] - assert log["authority"]["epoch"] == 2 - - -def test_demote_fences_local_room_against_newer_epoch(home): - from gateway.hosted_rooms import local_authority_gateway_id - - _result( - srv._methods["groups.create"]( - 1, - { - "room_id": "room-1", - "name": "Local room", - "members": [ - { - "member_id": "default", - "profile": "default", - "handle": "hermes", - }, - {"member_id": "ops", "profile": "ops", "handle": "ops"}, - ], - }, - ) - ) - observed_gateway = "install:" + "b" * 32 - result = _result( - srv._methods["groups.demote"]( - 2, - { - "room_id": "room-1", - "observed_gateway_id": observed_gateway, - "observed_epoch": 2, - }, - ) - ) - assert result["idempotent"] is False - assert result["authority_gateway_id"] == observed_gateway - - # Local sends at the stale authority now fail. - envelope = srv._methods["groups.send"]( - 3, - { - "room_id": "room-1", - "event_id": "stale-send", - "actor": {"kind": "user", "id": "tek"}, - "payload": {"text": "should fence"}, - }, - ) - assert "error" in envelope - assert local_authority_gateway_id() != observed_gateway - - -def test_replicate_rejects_gapped_page(home, tmp_path): - from gateway import hosted_rooms as rooms - - _authority_page(tmp_path, n=5) - db = tmp_path / "remote-authority.db" - gapped = rooms.read_events(db, room_id="room-1", since_seq=2, limit=100) - envelope = srv._methods["groups.replicate"]( - 1, - { - "room_id": "room-1", - "room_name": "Field Room", - "members": MEMBERS, - "page": gapped, - }, - ) - assert _error(envelope)["code"] == 4116 + assert "log_replication" not in result["features"] + assert "authority_takeover" not in result["features"] + assert "groups.replicate" not in result["methods"] + assert "groups.replicate" not in srv._LONG_HANDLERS + assert "groups.replica_state" in result["methods"] + assert "groups.replica_state" in srv._LONG_HANDLERS + blocked = srv._methods["groups.replicate"]( + 2, + {"room_id": "forged", "page": {"authority": {"epoch": 1}}}, + ) + assert _error(blocked)["data"]["reason"] == "replica_provenance_required" + for name in ("groups.promote", "groups.demote"): + assert name not in result["methods"] + assert name not in srv._LONG_HANDLERS + envelope = srv._methods[name](3, {"room_id": "room-1", "confirm": True}) + assert _error(envelope)["data"]["reason"] == "authority_takeover_disabled" diff --git a/tui_gateway/methods_groups.py b/tui_gateway/methods_groups.py index 2130bee00c14d..e60831db47e77 100644 --- a/tui_gateway/methods_groups.py +++ b/tui_gateway/methods_groups.py @@ -22,10 +22,7 @@ "groups.rename", "groups.log", "groups.disband", - "groups.replicate", "groups.replica_state", - "groups.promote", - "groups.demote", "groups.stop", "groups.retry", "groups.approve", @@ -258,8 +255,6 @@ def _(rid, params: dict) -> dict: "replayable_disband", "typed_events", "actor_identity", - "log_replication", - "authority_takeover", ], "methods": [ "groups.capabilities", @@ -270,10 +265,7 @@ def _(rid, params: dict) -> dict: "groups.rename", "groups.log", "groups.disband", - "groups.replicate", "groups.replica_state", - "groups.promote", - "groups.demote", "groups.stop", "groups.retry", "groups.approve", @@ -772,28 +764,13 @@ def _(rid, params: dict) -> dict: @method("groups.replicate") def _(rid, params: dict) -> dict: - """Persist one authority-stamped replay page into the local replica store. - - ``page`` is the verbatim ``groups.log`` result read from the room's - authority gateway; ingest is idempotent and refuses sequence gaps and - authority-epoch regressions. - """ - from gateway.hosted_room_replicas import ReplicaError, ingest_page - from gateway.hosted_rooms import default_db_path - - try: - result = ingest_page( - default_db_path(), - room_id=params.get("room_id"), - room_name=params.get("room_name"), - members=params.get("members"), - page=params.get("page"), - ) - return _ok(rid, result) - except ReplicaError as exc: - return _err(rid, 4116, str(exc)) - except Exception as exc: - return _err(rid, 5116, str(exc)) + """Fail closed until replica ingest is bound to verified RoomLink claims.""" + return _err( + rid, + 4116, + "Group Chat replication requires a verified RoomLink grant.", + {"reason": "replica_provenance_required"}, + ) @method("groups.replica_state") @@ -805,61 +782,33 @@ def _(rid, params: dict) -> dict: try: return _ok(rid, replica_state(default_db_path(), room_id=params.get("room_id"))) except ReplicaError as exc: - return _err(rid, 4117, str(exc)) + reason = getattr(exc, "reason", None) + return _err(rid, 4117, str(exc), {"reason": reason} if reason else None) except Exception as exc: return _err(rid, 5117, str(exc)) @method("groups.promote") def _(rid, params: dict) -> dict: - """Continue a replicated room on THIS gateway at ``epoch + 1``. - - Requires ``confirm: true`` — the caller asserts the previous authority can - no longer commit (explicit user action; a lease/quorum driver later). - """ - from gateway.hosted_room_replicas import ReplicaError, promote_replica - from gateway.hosted_rooms import HostedRoomError, default_db_path - - if params.get("confirm") is not True: - return _err( - rid, - 4118, - "promotion requires confirm=true acknowledging the previous " - "authority can no longer commit", - ) - try: - result = promote_replica( - default_db_path(), - room_id=params.get("room_id"), - reason=params.get("reason", "authority-unreachable"), - ) - return _ok(rid, result) - except ReplicaError as exc: - return _err(rid, 4118, str(exc)) - except HostedRoomError as exc: - return _err(rid, 4118, str(exc)) - except Exception as exc: - return _err(rid, 5118, str(exc)) + """Fail closed for clients that cached the retired takeover method.""" + return _err( + rid, + 4118, + "Group Chat takeover is disabled until Hermes can select one globally " + "exclusive authority.", + {"reason": "authority_takeover_disabled"}, + ) @method("groups.demote") def _(rid, params: dict) -> dict: - """Fence this gateway's stale room authority against a proven newer epoch.""" - from gateway.hosted_room_replicas import ReplicaError, demote_room - from gateway.hosted_rooms import default_db_path - - try: - result = demote_room( - default_db_path(), - room_id=params.get("room_id"), - observed_gateway_id=params.get("observed_gateway_id"), - observed_epoch=params.get("observed_epoch"), - ) - return _ok(rid, result) - except ReplicaError as exc: - return _err(rid, 4119, str(exc)) - except Exception as exc: - return _err(rid, 5119, str(exc)) + """Fail closed for clients that cached the retired demotion method.""" + return _err( + rid, + 4119, + "Group Chat authority changes require a verified takeover decision.", + {"reason": "authority_takeover_disabled"}, + ) def register(server) -> None: From 22b26d04ca6719a4e87326681079dc1c040eabbc Mon Sep 17 00:00:00 2001 From: jugol Date: Thu, 27 Aug 2026 16:17:44 +0900 Subject: [PATCH 02/16] fix(desktop): enable cross-source group chat creation --- .../src/plugins/hermes-bots/roster-pane.tsx | 10 ++++++++-- .../src/plugins/hermes-bots/row-helpers.test.ts | 17 +++++++++++++++++ .../src/plugins/hermes-bots/row-helpers.ts | 7 +++++++ contributors/emails/solomoj94@gmail.com | 2 ++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 contributors/emails/solomoj94@gmail.com diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx index 60b561961d2b0..bc9c23fe79a19 100644 --- a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx @@ -76,7 +76,13 @@ import { } from './roster-sections' import type { ResolvedRosterGatewaySection } from './roster-sections' import { botRosterMeta, botWorkspaceOwnerKey, setBotsWorkspaceOwner } from './routing' -import { ACTIVE_WINDOW_S, activeBots, BOT_ROSTER_SEARCH_THRESHOLD, rosterActivityMatches } from './row-helpers' +import { + ACTIVE_WINDOW_S, + activeBots, + BOT_ROSTER_SEARCH_THRESHOLD, + canCreateGroupChat, + rosterActivityMatches +} from './row-helpers' import { backfillMessagingProtocol } from './soul' import type { BotMeta, GatewaySource, GroupMember, RosterActivityFilter, RosterKindFilter, RosterRow } from './types' @@ -637,7 +643,7 @@ export function BotsPane() { {b.bot.newTitle} - setGroupCreateOpen(true)}> + setGroupCreateOpen(true)}> {b.group.newTitle} diff --git a/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts b/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts index 9182249e00b8d..c74350df6aad0 100644 --- a/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts @@ -19,6 +19,7 @@ import { activeBots, botCanonicalSessionId, botRowOwnsWorkspace, + canCreateGroupChat, previewKind, rosterActivityMatches, workerActiveAt @@ -157,6 +158,22 @@ describe('which bots are working right now', () => { }) }) +describe('Group Chat creation availability', () => { + it('counts reachable local and remote Bots together', () => { + expect( + canCreateGroupChat([ + row({ connectionId: 'local', name: 'local' }), + row({ connectionId: 'remote-a', name: 'remote', remoteSource: true }) + ]) + ).toBe(true) + }) + + it('excludes offline ghost placeholders and tolerates an unresolved roster', () => { + expect(canCreateGroupChat([row({ name: 'local' }), row({ ghost: true, name: 'offline' })])).toBe(false) + expect(canCreateGroupChat(undefined)).toBe(false) + }) +}) + describe('the roster activity filter', () => { it('passes everything through with no filter', () => { expect(rosterActivityMatches({ activity: 0 }, null, NOW)).toBe(true) diff --git a/apps/desktop/src/plugins/hermes-bots/row-helpers.ts b/apps/desktop/src/plugins/hermes-bots/row-helpers.ts index 2c92daeafda57..d7699af10e403 100644 --- a/apps/desktop/src/plugins/hermes-bots/row-helpers.ts +++ b/apps/desktop/src/plugins/hermes-bots/row-helpers.ts @@ -57,6 +57,13 @@ export const ACTIVE_WINDOW_S = 90 const RECENT_ACTIVITY_WINDOW_S = 7 * 24 * 60 * 60 export const BOT_ROSTER_SEARCH_THRESHOLD = 8 +/** A Group Chat can seat every reachable roster row, regardless of which + * gateway is currently active. Ghost rows preserve offline identity but are + * not routable participants. */ +export function canCreateGroupChat(roster: null | RosterRow[] | undefined): boolean { + return (roster || []).filter(bot => !bot?.ghost).length >= 2 +} + /** The stored session id this bot's canonical Bot Chat answers to — the * compression-lineage tip the live-state atoms are keyed by, falling back to * the durable registry id. THE id for anything core-keyed: the row's status diff --git a/contributors/emails/solomoj94@gmail.com b/contributors/emails/solomoj94@gmail.com new file mode 100644 index 0000000000000..3207318c06a99 --- /dev/null +++ b/contributors/emails/solomoj94@gmail.com @@ -0,0 +1,2 @@ +jugol +# source: #96162 From 52f80019d24e97e53db4e20578bcd1a417393510 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:23:45 +0200 Subject: [PATCH 03/16] feat(bot-mode): add automatic Group Chat continuity --- .../src/plugins/hermes-bots/create-dialog.tsx | 250 ++- .../plugins/hermes-bots/group-chat-parts.tsx | 1 + .../hermes-bots/group-chat-view.test.ts | 132 +- .../plugins/hermes-bots/group-chat-view.tsx | 184 +- .../plugins/hermes-bots/group-chat.test.ts | 52 + .../src/plugins/hermes-bots/group-chat.ts | 345 +++- .../group-continuity-creation.test.tsx | 412 ++++ .../group-membership-controls.test.tsx | 98 + .../src/plugins/hermes-bots/group-rounds.ts | 198 +- .../hermes-bots/hosted-room-client.test.ts | 506 +++++ .../plugins/hermes-bots/hosted-room-client.ts | 1220 ++++++++++++ .../hermes-bots/hosted-room-runtime.test.ts | 1685 ++++++++++++++++ .../hermes-bots/hosted-room-runtime.ts | 1708 +++++++++++++++++ .../src/plugins/hermes-bots/i18n.test.ts | 39 + apps/desktop/src/plugins/hermes-bots/i18n.ts | 250 ++- .../plugins/hermes-bots/plugin-panes.test.tsx | 44 +- .../src/plugins/hermes-bots/plugin.tsx | 50 +- apps/desktop/src/plugins/hermes-bots/types.ts | 23 + 18 files changed, 7098 insertions(+), 99 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/group-membership-controls.test.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts diff --git a/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx b/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx index 79c339d749be8..7688a8109a29c 100644 --- a/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx +++ b/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx @@ -45,7 +45,14 @@ import { $selectedBot } from './bot-state' import { createCanonicalChat } from './canonical-chat' import { $botMeta, botHandle, botRosterKey, filterBots, ROSTER_KEY, saveBotMeta } from './data' import { labeled, ResizableFrame } from './dialog-parts' -import { GROUP_CHAT_MAX_MEMBERS, mintGroupRoomId, uniqueGroupChatName, updateGroupChat } from './group-chat' +import { + $groupChats, + GROUP_CHAT_MAX_MEMBERS, + groupChatHostedGateway, + mintGroupRoomId, + uniqueGroupChatName, + updateGroupChat +} from './group-chat' import type { GroupChatRoom } from './group-chat' import { GroupImageControls } from './group-chat-parts' import { @@ -55,6 +62,13 @@ import { knownGroups, liveGroupChatNames } from './group-membership' +import { + createAutonomousHostedGroupChat, + describeHostedRoomCreationError, + markHostedRoomLocallyDeleted, + probeHostedRoomMembers +} from './hosted-room-runtime' +import type { HostedRoomProbe } from './hosted-room-runtime' import { useBots } from './i18n' import { displayName, slugify } from './labels' import { McpSetupButton } from './mcp-setup' @@ -1026,11 +1040,23 @@ interface GroupDialogProps { export function GroupDialog({ bot, onClose }: GroupDialogProps) { const b = useBots() const meta = useValue($botMeta) + const rooms = useValue($groupChats) const [name, setName] = useState('') const current = botGroups(botRosterMeta(bot, meta)) const groups = knownGroups(meta) + const hostedCurrent = current.filter(group => groupChatHostedGateway(rooms[group])) + const removableCurrent = current.filter(group => !groupChatHostedGateway(rooms[group])) const setMembership = (group: string, enabled: boolean) => { + if (groupChatHostedGateway(rooms[group])) { + host.notify({ + kind: 'info', + message: b.group.hostedMembersFixed + }) + + return + } + void saveBotMeta(bot, groupMembershipPatch(botRosterMeta(bot, meta), group, enabled)) host.notify({ kind: 'info', @@ -1058,13 +1084,21 @@ export function GroupDialog({ bot, onClose }: GroupDialogProps) {
{groups.map(group => { const enabled = current.includes(group) + const hosted = Boolean(groupChatHostedGateway(rooms[group])) return ( ) @@ -1093,19 +1127,19 @@ export function GroupDialog({ bot, onClose }: GroupDialogProps) { Create & join - {current.length ? ( + {removableCurrent.length ? ( ) : null} @@ -1132,6 +1166,9 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea const [checked, setChecked] = useState>({}) const [name, setName] = useState('') const [image, setImage] = useState(null) + const [hostProbe, setHostProbe] = useState(null) + const [createPending, setCreatePending] = useState(false) + const [createError, setCreateError] = useState('') // Reset per open so a cancelled draft doesn't leak into the next one. useEffect(() => { @@ -1140,6 +1177,9 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea setChecked({}) setName('') setImage(null) + setHostProbe(null) + setCreatePending(false) + setCreateError('') } }, [open]) @@ -1155,8 +1195,49 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea : b.group.nameLabel const canCreate = selected.length >= 2 && Boolean(name.trim() || selected.length) + const selectedRouteKey = selected.map(botRosterKey).sort().join('|') + const resolvedProbe = hostProbe?.key === selectedRouteKey ? hostProbe.probe : null + const hostProbePending = selected.length >= 2 && hostProbe?.key !== selectedRouteKey - const create = () => { + useEffect(() => { + let cancelled = false + + if (!open || selected.length < 2) { + setHostProbe(null) + + return () => { + cancelled = true + } + } + + void probeHostedRoomMembers(durableGroupChatMembers(selected)) + .then(probe => { + if (cancelled) { + return + } + + setHostProbe({ + key: selectedRouteKey, + probe + }) + }) + .catch(() => { + if (!cancelled) { + setHostProbe({ + key: selectedRouteKey, + probe: null + }) + } + }) + + return () => { + cancelled = true + } + // Stable member ownership, not object identity, is the probe boundary. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, selectedRouteKey]) + + const create = async () => { const base = (name.trim() || placeholder).slice(0, 64) if (selected.length < 2 || !base) { @@ -1180,32 +1261,126 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea } const groupName = uniqueGroupChatName(base, taken) - const roomId = mintGroupRoomId() + let roomId = mintGroupRoomId() - for (const bot of selected) { - void saveBotMeta(bot, groupMembershipPatch(botRosterMeta(bot, allMeta), groupName, true)) - } + setCreatePending(true) + setCreateError('') + + try { + if (hostProbePending) { + return + } - // Persist every machine identity, including today's active source. That - // member becomes remote after a source switch and cannot rely on the new - // gateway's name-keyed bot metadata to remain seated in this room. - const roomMembers = durableGroupChatMembers(selected) - updateGroupChat(groupName, (room: GroupChatRoom) => { - room.members = roomMembers - room.roomId = roomId + // RPCs below can refresh or switch the live roster before they settle. + // Capture immutable member ownership now so the local projection matches + // the exact source routes used to create the hosted room. + const roomMembers = durableGroupChatMembers(selected) - if (image) { - room.image = image + const metadataOwners = selected.map(bot => ({ + ...bot, + ...(bot.route + ? { + route: { ...bot.route } + } + : {}) + })) + + const autonomousMembers = roomMembers.map((member, index) => { + const bot = selected[index] + const label = displayName(bot, botRosterMeta(bot, allMeta)) + + return { + member, + profile: member.targetProfile || member.name, + handle: botHandle(member.name, member), + ...(label + ? { + displayName: label + } + : {}) + } + }) + + const hostName = selected[0]?.connectionLabel || b.group.thisHost + let hosted: Awaited> | null = null + + if (resolvedProbe?.eligible) { + try { + hosted = await createAutonomousHostedGroupChat({ + probe: resolvedProbe, + roomId, + name: groupName, + members: autonomousMembers + }) + } catch (error) { + if ((error as { fallbackSafe?: boolean })?.fallbackSafe === false) { + setCreateError(describeHostedRoomCreationError(error) || b.group.createFailed) + + return + } + + const retiredRoomId = roomId + + markHostedRoomLocallyDeleted(retiredRoomId) + + const rooms = $groupChats.get() + const withoutRetiredProjection = Object.fromEntries( + Object.entries(rooms).filter(([, room]) => room.roomId !== retiredRoomId) + ) + + if (Object.keys(withoutRetiredProjection).length !== Object.keys(rooms).length) { + $groupChats.set(withoutRetiredProjection) + } + + roomId = mintGroupRoomId() + + host.notify({ + kind: 'info', + message: b.group.hostedFallbackToDesktop(hostName) + }) + } } - return room - }) - host.notify({ - kind: 'info', - message: `“${groupName}” created with ${selected.length} bots` - }) - onClose() - onCreated?.(groupName) + for (const owner of metadataOwners) { + await saveBotMeta(owner, groupMembershipPatch(botRosterMeta(owner, allMeta), groupName, true)) + } + + // Persist every machine identity, including today's active source. That + // member becomes remote after a source switch and cannot rely on the new + // gateway's name-keyed bot metadata to remain seated in this room. + updateGroupChat(groupName, (room: GroupChatRoom) => { + room.members = roomMembers + room.roomId = roomId + room.continuityMode = hosted?.continuityMode || 'desktop' + + if (hosted) { + room.hosted = hosted.authorityId + room.hostedEpoch = hosted.authorityEpoch + room.hostedConnectionId = hosted.connectionId + room.hostedSeq = 0 + room.hostedStatus = { + state: 'ready', + label: b.roster.ready + } + } + + if (image) { + room.image = image + } + + return room + }) + host.notify({ + kind: 'info', + message: b.group.created(groupName, selected.length) + }) + onClose() + onCreated?.(groupName) + } catch { + setCreateError(b.group.createFailed) + } finally { + setCreatePending(false) + } } return ( @@ -1220,7 +1395,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea {b.group.newTitle} - {`Pick 2–${GROUP_CHAT_MAX_MEMBERS} bots. Local memberships sync through each Bot profile; cross-machine members stay scoped to this room.`} + {b.group.newDesc} {/* TODO(bot-mode-types): this search box never takes focus when the dialog opens — SearchField accepts no `autoFocus` prop and forwards no extra @@ -1243,13 +1418,13 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea variant="muted" > setChecked(prev => ({ ...prev, [botRosterKey(bot)]: false })) } - title={b.group.removeFromSelection} > {displayName(bot, botRosterMeta(bot, allMeta))} @@ -1309,7 +1484,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea }) ) : (
- {query.trim() ? `No bots match “${query.trim()}”` : 'No bots yet — create one first.'} + {query.trim() ? b.roster.noMatchQuery(query.trim()) : b.group.noBots}
)}
@@ -1324,7 +1499,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
{ event.preventDefault() - create() + void create() }} >
+ {createError ?
{createError}
: null} - + diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx b/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx index ecc13870b5442..44458b37825d0 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx @@ -159,6 +159,7 @@ interface GroupMentionInputProps { 'aria-label'?: string autoFocus?: boolean className?: string + disabled?: boolean members: GroupMember[] onChange: (value: string) => void onPaste?: (event: ClipboardEvent) => void diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-view.test.ts b/apps/desktop/src/plugins/hermes-bots/group-chat-view.test.ts index 4c07972f2426b..3562d064bfcfa 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-view.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-view.test.ts @@ -13,7 +13,14 @@ import type { GroupChat, RosterRow } from './types' // window, and disbanding one — plus the ordering rules that keep the in-pane // fallback from painting a duplicate beside the main tab. -const { host } = vi.hoisted(() => ({ host: {} as Record })) +const { beginHostedRoomMutation, disbandHostedGroupChat, host, markHostedRoomLocallyDeleted, renameHostedGroupChat } = + vi.hoisted(() => ({ + beginHostedRoomMutation: vi.fn(() => 1), + disbandHostedGroupChat: vi.fn(async () => true), + host: {} as Record, + markHostedRoomLocallyDeleted: vi.fn(), + renameHostedGroupChat: vi.fn(async () => true) + })) vi.mock('@hermes/plugin-sdk', async () => { const { pluginSdkMock } = await import('./group-test-utils') @@ -21,6 +28,16 @@ vi.mock('@hermes/plugin-sdk', async () => { return pluginSdkMock(host) }) +vi.mock('./hosted-room-runtime', () => ({ + beginHostedRoomMutation, + disbandHostedGroupChat, + markHostedRoomLocallyDeleted, + readHostedGroupChatAttachment: vi.fn(), + renameHostedGroupChat, + retryHostedGroupChat: vi.fn(), + retryHostedRoomReplay: vi.fn() +})) + interface Room { chat: typeof groupChat data: typeof data @@ -57,9 +74,48 @@ async function loadRoom(): Promise { const durable = (room: Room) => (room.gateway.storage.get('group-chats') || {}) as Record beforeEach(() => { + vi.clearAllMocks() + disbandHostedGroupChat.mockResolvedValue(true) + renameHostedGroupChat.mockResolvedValue(true) runTimersInline() }) +describe('renaming a hosted Group Chat', () => { + it('queues the durable rename before re-keying the local room', async () => { + const room = await loadRoom() + + renameHostedGroupChat.mockResolvedValue(false) + room.chat.$groupChats.set({ + Core: { + continuityMode: 'gateway', + hosted: 'install:studio', + hostedConnectionId: 'host-a', + hostedEpoch: 1, + hostedSeq: 2, + log: [], + members: [ + { + connectionId: 'host-a', + connectionLabel: 'Studio', + name: 'research' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + + await expect(room.view.renameGroupChat('Core', 'Launch', [])).resolves.toBe('Launch') + + expect(renameHostedGroupChat).toHaveBeenCalledWith('Core', 'Launch') + expect(room.chat.$groupChats.get()).not.toHaveProperty('Core') + expect(room.chat.$groupChats.get().Launch).toMatchObject({ + hosted: 'install:studio', + continuityIssue: 'Rename saved. It will sync when Studio is online.' + }) + }) +}) + describe('opening a room', () => { it('follows the main-window tab open and close', async () => { const room = await loadRoom() @@ -138,6 +194,80 @@ describe('opening a room', () => { }) describe('disband', () => { + it('fences an idle hosted deletion before removing local state', async () => { + const room = await loadRoom() + + room.chat.$groupChats.set({ + Hosted: { + continuityMode: 'gateway', + hosted: 'install:home', + hostedConnectionId: 'gateway-a', + hostedEpoch: 1, + log: [], + members: [], + roomId: 'room-hosted', + running: false, + watermarks: {} + } + }) + + await room.view.disbandGroupChat('Hosted', []) + + expect(beginHostedRoomMutation).toHaveBeenCalledWith('room-hosted') + expect(disbandHostedGroupChat).toHaveBeenCalledWith('Hosted') + expect(markHostedRoomLocallyDeleted).toHaveBeenCalledWith('room-hosted') + expect(room.chat.$groupChats.get().Hosted).toBeUndefined() + }) + + it('removes a remotely deleted hosted room locally without requiring the authority again', async () => { + const room = await loadRoom() + + room.chat.$groupChats.set({ + Deleted: { + continuityMode: 'gateway', + hosted: 'install:home', + hostedConnectionId: 'gateway-a', + hostedEpoch: 1, + hostedStatus: { label: 'Deleted', state: 'deleted' }, + log: [], + members: [], + roomId: 'room-deleted', + running: false, + watermarks: {} + } + }) + + await room.view.disbandGroupChat('Deleted', []) + + expect(disbandHostedGroupChat).not.toHaveBeenCalled() + expect(markHostedRoomLocallyDeleted).toHaveBeenCalledWith('room-deleted') + expect(room.chat.$groupChats.get().Deleted).toBeUndefined() + }) + + it('keeps a hosted room when its authority cannot confirm deletion', async () => { + const room = await loadRoom() + + disbandHostedGroupChat.mockRejectedValueOnce(new Error('Reconnect Studio to delete this Group Chat.')) + room.chat.$groupChats.set({ + Hosted: { + continuityMode: 'gateway', + hosted: 'install:home', + hostedConnectionId: 'gateway-a', + hostedEpoch: 1, + log: [], + members: [], + roomId: 'room-hosted', + running: false, + watermarks: {} + } + }) + + await expect(room.view.disbandGroupChat('Hosted', [])).rejects.toThrow('Reconnect Studio') + + expect(markHostedRoomLocallyDeleted).not.toHaveBeenCalled() + expect(room.chat.$groupChats.get().Hosted).toBeTruthy() + }) + it('removes only this membership, room log, workspace and needs-you state', async () => { const room = await loadRoom() room.chat.$groupChats.set({ diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx index e9749dc3c383e..b386a8ec6b309 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx @@ -63,8 +63,11 @@ import { $groupChatWorkspace, $groupClarify, $groupNeedsYou, + groupChatContinuityMode, + groupChatHostedGateway, groupSpeakerLabel, groupThreadOf, + groupThreadReplyCount, scheduleGroupChatServerSync, setGroupChatImage, updateGroupChat @@ -95,6 +98,14 @@ import { import type { GroupComposerDraft, GroupDraftSetter } from './group-panes' import { sendToGroupChat, stopGroupThread } from './group-rounds' import { clearGroupClarify } from './group-turns' +import { + beginHostedRoomMutation, + disbandHostedGroupChat, + markHostedRoomLocallyDeleted, + renameHostedGroupChat, + retryHostedGroupChat, + retryHostedRoomReplay +} from './hosted-room-runtime' import { botsText, useBots } from './i18n' import { displayName, slugify, stripPreviewMarkdown } from './labels' import { botRosterMeta, setBotsWorkspaceOwner } from './routing' @@ -117,6 +128,27 @@ export async function disbandGroupChat(group: string, members: RosterRow[]) { } const prior = all[group] || {} + + if (groupChatHostedGateway(prior)) { + const roomId = String(prior.roomId || '') + const alreadyDeleted = prior.hostedStatus?.state === 'deleted' + + if (!alreadyDeleted) { + beginHostedRoomMutation(roomId) + const acknowledged = await disbandHostedGroupChat(group) + + if (!acknowledged) { + throw new Error( + botsText().group.hostedReconnectToDelete( + prior.members?.find(member => member.connectionLabel)?.connectionLabel || botsText().group.thisHost + ) + ) + } + } + + markHostedRoomLocallyDeleted(roomId) + } + const metaBefore = $botMeta.get() const cleanup = groupDisbandMetadataPlan(group, members, prior, $lastRoster.get(), metaBefore) let metadataPersistence: Promise = Promise.resolve() @@ -192,6 +224,12 @@ export async function disbandGroupChat(group: string, members: RosterRow[]) { sessionOwners: room.sessionOwners || {}, members: Array.isArray(room.members) ? room.members : [], roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + hosted: groupChatHostedGateway(room) || null, + hostedEpoch: Math.max(0, Number(room.hostedEpoch || 0)) || null, + hostedConnectionId: + typeof room.hostedConnectionId === 'string' && room.hostedConnectionId ? room.hostedConnectionId : null, + hostedSeq: Math.max(0, Number(room.hostedSeq || 0)), + continuityMode: groupChatContinuityMode(room), image: room.image || null, syncRevision: Math.max(0, Number(room.syncRevision || 0)) } @@ -238,7 +276,12 @@ export async function disbandGroupChat(group: string, members: RosterRow[]) { * rename, so even a member whose sid is later lost falls back to the same * "Group: " title lookup instead of a fresh "Group: ". * Returns the new name, or null when the target name is taken. */ -async function renameGroupChat(oldName: string, newName: string, members: GroupMember[] | null | undefined) { +export async function renameGroupChat( + oldName: string, + newName: string, + members: GroupMember[] | null | undefined, + { hostedAlreadyRenamed = false }: { hostedAlreadyRenamed?: boolean } = {} +) { const next = String(newName || '') .trim() .slice(0, 64) @@ -269,6 +312,37 @@ async function renameGroupChat(oldName: string, newName: string, members: GroupM return null } + const beforeRename = $groupChats.get()[oldName] + + if (groupChatHostedGateway(beforeRename) && !hostedAlreadyRenamed) { + const connectionName = + beforeRename.members?.find(member => member.connectionLabel)?.connectionLabel || botsText().group.thisHost + + try { + const acknowledged = await renameHostedGroupChat(oldName, next) + + if (!acknowledged) { + updateGroupChat( + oldName, + current => ({ + ...current, + continuityIssue: botsText().group.hostedRenameQueued(connectionName) + }), + { + sync: false + } + ) + } + } catch { + host.notify({ + kind: 'error', + message: botsText().group.hostedRenameFailed(connectionName) + }) + + return null + } + } + // Move the room record wholesale — log, watermarks, sessions, members, // picture, and runtime flags all belong to the same room under its new name. const all: Record = { @@ -367,7 +441,15 @@ function GroupChatSettingsDialog({ group, members, open, onClose, onRenamed }: G const { t } = useI18n() const b = useBots() const rooms: Record = useValue($groupChats) - const current = (rooms[group] || {}).image || null + const room = rooms[group] || {} + const current = room.image || null + const hosted = Boolean(groupChatHostedGateway(room)) + const hostedState = String(room.hostedStatus?.state || '') + + const renameBlocked = + hosted && (room.running === true || ['queued', 'sending', 'stopping', 'working'].includes(hostedState)) + + const continuity = groupChatContinuityMode(room) const [name, setName] = useState(group) const [image, setImage] = useState(current) useEffect(() => { @@ -379,6 +461,10 @@ function GroupChatSettingsDialog({ group, members, open, onClose, onRenamed }: G }, [open, group]) const save = async () => { + if (renameBlocked) { + return + } + const finalName = await renameGroupChat(group, name, members) if (finalName === null) { @@ -410,6 +496,14 @@ function GroupChatSettingsDialog({ group, members, open, onClose, onRenamed }: G {b.group.settingsTitle} {b.group.settingsDesc} +
+
+ {continuity === 'desktop' ? b.group.continuityDesktopTitle : b.group.continuityOnTitle} +
+
+ {continuity === 'desktop' ? b.group.continuityDesktopDesc : b.group.continuityOnDesc} +
+
setName(event.target.value)} value={name} @@ -434,7 +529,7 @@ function GroupChatSettingsDialog({ group, members, open, onClose, onRenamed }: G - @@ -472,6 +567,11 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G log: [], running: false } + const hostedState = String(room.hostedStatus?.state || '') + const hostedDeleted = Boolean(groupChatHostedGateway(room) && hostedState === 'deleted') + const canStop = Boolean( + room.running && hostedState !== 'stopping' && room.hostedStatus?.canStop !== false + ) const composerKey = groupComposerDraftKey(group, room) const composerKeyRef = useRef(composerKey) @@ -519,6 +619,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G })) const [confirmDisband, setConfirmDisband] = useState(false) + const [confirmRetry, setConfirmRetry] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) // Click-to-disambiguate: which log entry is showing its speaker's full // @handle (the roster's name-device form when names collide across @@ -611,6 +712,10 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G // Ctrl/⌘-V a screenshot (or any file) into any composer in this room. const pasteImages = (thread: null | string, event: ClipboardEvent) => { + if (hostedDeleted) { + return + } + const files = [...(event.clipboardData?.files || [])] if (!files.length) { @@ -628,13 +733,23 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G const dropFiles = (event: DragEvent) => { const files = [...(event.dataTransfer?.files || [])] + + if (files.length) { + event.preventDefault() + } + + if (hostedDeleted) { + setDragOver(false) + + return + } + setDragOver(false) if (!files.length) { return } - event.preventDefault() void filesToGroupAttachments(files).then(picked => addImages(replyThread, picked)) } @@ -723,12 +838,18 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G // Events are epoch-tagged, so a superseded run's history drops out of view. const activityEvents: GroupActivityEntry[] = currentGroupActivity(group) const latestActivity = activityEvents.length ? activityEvents[activityEvents.length - 1] : null + const hostedActivity = groupChatHostedGateway(room) ? room.hostedStatus?.label : null + const retryTaskId = String(room.hostedStatus?.taskId || '') // #94570 shell rewired onto the real primitive (#91868/#94569): the button // must stop the ROUND, not just spray per-member interrupts — without the // epoch bump + holds the loop marched on to the next member. Thread scope: // the run being stopped is the one the latest activity belongs to. const stopRoomRun = async () => { + if (!canStop) { + return + } + await stopGroupThread(group, latestActivity?.thread || null, memberDescriptors()) host.notify({ kind: 'success', @@ -748,11 +869,13 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G > {b.group.activity} - {latestActivity ? ( + {hostedActivity ? ( + {hostedActivity} + ) : latestActivity ? ( {`${groupActivityLabel(latestActivity)} · ${relativeTime(latestActivity.at)}`} ) : null} - {room.running ? ( + {canStop ? ( + ) : null} + {room.continuityIssue ? ( +
{room.continuityIssue}
+ ) : null} {activityOpen ? (
{activityEvents.length ? ( @@ -779,7 +916,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G {groupActivityLabel(event)} {relativeTime(event.at)} - {event.kind === 'working' ? ( + {canStop && event.kind === 'working' ? (
@@ -1218,7 +1361,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G } }} onDragOver={event => { - if ([...(event.dataTransfer?.types || [])].includes('Files')) { + if (!hostedDeleted && [...(event.dataTransfer?.types || [])].includes('Files')) { event.preventDefault() setDragOver(true) } @@ -1256,9 +1399,11 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
{roomClarifies.length ? b.group.waitingForAnswer - : room.turn - ? b.group.memberThinking(groupSpeakerLabel(room.turn)) - : b.group.roomWorking} + : groupChatHostedGateway(room) && room.hostedStatus?.label + ? room.hostedStatus.label + : room.turn + ? b.group.memberThinking(groupSpeakerLabel(room.turn)) + : b.group.roomWorking}
) : null} {/* Scroll anchor (#89835): rooms opened at scroll position 0, mid- */ @@ -1279,6 +1424,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
pasteImages(null, event)} @@ -1287,7 +1433,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G value={draft} /> {attachButton(null)} -
@@ -1327,6 +1473,16 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G open={confirmDisband} title={b.group.disbandTitle} /> + setConfirmRetry(false)} + onConfirm={async () => { + await retryHostedGroupChat(group, retryTaskId) + }} + open={confirmRetry} + title={b.group.retryTitle} + /> ) } diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts index b88c7056d17fd..2e1ae75c40181 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts @@ -273,6 +273,22 @@ describe('duplicate append guard (#93127)', () => { }) describe('threads', () => { + it('repairs cached gateway-second timestamps during hydration', async () => { + const room = await loadRoom() + const seconds = 1_787_969_590.436 + + expect( + room.chat.assignLegacyThreads([ + { + at: seconds, + from: { kind: 'user', name: 'You' }, + text: 'Hello', + thread: 'thread-1' + } + ])[0].at + ).toBe(seconds * 1000) + }) + it('hydration assigns legacy thread ids — a lull splits, follow-ups stay together', async () => { const { chat } = await loadRoom() const minute = 60000 @@ -295,6 +311,25 @@ describe('threads', () => { expect(log[0].thread).not.toBe(log[4].thread) expect(log[4].thread).toBe(log[5].thread) }) + + it('counts only visible replies after the thread head', async () => { + const { chat } = await loadRoom() + + const log = [ + { at: 1, from: { kind: 'user', name: 'You' }, text: 'Start', thread: 'thread-1' }, + { at: 2, from: { kind: 'member', name: 'research' }, text: '', thread: 'thread-1' }, + { at: 3, from: { kind: 'member', name: 'research' }, text: 'Visible', thread: 'thread-1' }, + { + at: 4, + from: { kind: 'member', name: 'builder' }, + images: [{ data: 'data:image/png;base64,x', kind: 'image', name: 'result.png' }], + text: '', + thread: 'thread-1' + } + ] as GroupMessage[] + + expect(chat.groupThreadReplyCount(log, 'thread-1')).toBe(2) + }) }) describe('durable projection', () => { @@ -468,6 +503,23 @@ describe('gateway mirror', () => { }) describe('snapshot merge', () => { + it('collapses identity-free projection echoes when authoritative replay arrives', async () => { + const { chat } = await loadRoom() + + const fallback = { + at: 100, + from: { kind: 'member' as const, name: 'research' }, + text: 'Complete', + thread: 'thread-1' + } + + const echoes = Array.from({ length: 96 }, () => ({ ...fallback })) + + const merged = chat.mergeGroupChatSyncEntries(echoes, [{ ...fallback, eventId: 'event-9', id: 'event-9', seq: 9 }]) + + expect(merged).toEqual([{ ...fallback, eventId: 'event-9', id: 'event-9', seq: 9 }]) + }) + it('pull-before-push preserves disjoint rooms, messages and members', async () => { const { chat } = await loadRoom() diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.ts index 9949c57824b15..f430e83a98f7b 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.ts @@ -56,6 +56,9 @@ let groupChatSyncTimer: ReturnType | null = null /** One room inside the bounded ui_meta projection: a compacted log plus the * identity fields, without any of `GroupChat`'s runtime/orchestration state. */ interface GroupChatSyncRoom { + continuityMode?: 'desktop' | 'distributed' | 'gateway' + hosted?: null | string + hostedEpoch?: null | number image?: null | string log: GroupMessage[] members?: GroupMember[] @@ -124,6 +127,95 @@ export function groupChatRoomKey(name: string, room: GroupChat) { return typeof room?.roomId === 'string' && room.roomId ? `id:${room.roomId}` : `name:${String(name)}` } +/** Stable authority id for a gateway-hosted room. Presence is an execution + * fence: a Desktop that cannot reach that gateway must not start a second + * local round driver for the same room. */ +export function groupChatHostedGateway(room: null | Partial> | undefined) { + return typeof room?.hosted === 'string' ? room.hosted.trim().slice(0, 128) : '' +} + +/** Monotonic authority epoch. Legacy hosted records predate the explicit + * field and safely mean epoch 1. */ +export function groupChatHostedEpoch(room: null | Partial> | undefined) { + const epoch = Number(room?.hostedEpoch || 0) + + if (Number.isSafeInteger(epoch) && epoch >= 1) { + return epoch + } + + return groupChatHostedGateway(room) ? 1 : 0 +} + +export function groupChatContinuityMode( + room: null | Partial> | undefined +) { + if (!groupChatHostedGateway(room)) { + return 'desktop' as const + } + + return room?.continuityMode === 'distributed' ? ('distributed' as const) : ('gateway' as const) +} + +/** Apply authority only from `groups.state`, never from the client-writable + * ui_meta display projection. A conflicting owner cannot replace an existing + * fence without a server-issued authority transfer receipt. */ +export function applyHostedRoomAuthority(room: GroupChat, serverRoom: Record): GroupChat { + const authorityGateway = groupChatHostedGateway({ + hosted: typeof serverRoom.authority_gateway_id === 'string' ? serverRoom.authority_gateway_id : null + }) + + const authorityEpoch = Number(serverRoom.authority_epoch || 0) + const roomId = typeof room?.roomId === 'string' ? room.roomId : '' + const serverRoomId = typeof serverRoom.room_id === 'string' ? serverRoom.room_id : '' + + if ( + !authorityGateway || + !Number.isSafeInteger(authorityEpoch) || + authorityEpoch < 1 || + (roomId && serverRoomId && roomId !== serverRoomId) + ) { + return room + } + + const currentGateway = groupChatHostedGateway(room) + const currentEpoch = groupChatHostedEpoch(room) + + const claim = + serverRoom.authority_claim && typeof serverRoom.authority_claim === 'object' + ? (serverRoom.authority_claim as Record) + : null + + const actor = claim?.actor && typeof claim.actor === 'object' ? (claim.actor as Record) : null + + const payload = + claim?.payload && typeof claim.payload === 'object' ? (claim.payload as Record) : null + + const transferProven = Boolean( + claim?.kind === 'authority.claimed' && + actor?.kind === 'system' && + actor?.id === 'authority-control' && + Number(claim?.authority_epoch || 0) === authorityEpoch && + payload?.previous_gateway_id === currentGateway && + payload?.authority_gateway_id === authorityGateway && + Number(payload?.authority_epoch || 0) === authorityEpoch + ) + + if ( + currentEpoch > authorityEpoch || + (currentGateway && currentGateway !== authorityGateway && !transferProven) || + (currentEpoch === authorityEpoch && currentGateway && currentGateway !== authorityGateway) + ) { + return room + } + + return { + ...room, + hosted: authorityGateway, + hostedEpoch: authorityEpoch, + continuityMode: room.continuityMode === 'distributed' ? 'distributed' : 'gateway' + } +} + /** Lift any historical projection shape (v1 wall-clock, v2 name-keyed) to * the v3 room-key shape so one merge path serves mixed-version fleets. */ function normalizeGroupChatSyncSnapshot(snapshot: GroupChatSyncSnapshot | null | undefined): GroupChatSyncSnapshot { @@ -245,6 +337,12 @@ export function groupChatSyncSnapshot( roomId: String(room.roomId).slice(0, 128) } : {}), + // Presence is the mixed-version contract. Older clients omit these + // fields; hosted-aware clients must preserve an existing non-empty + // authority fence instead of interpreting omission as a local takeover. + hosted: groupChatHostedGateway(room) || null, + hostedEpoch: groupChatHostedEpoch(room) || null, + continuityMode: groupChatContinuityMode(room), log, revision: Math.max(0, Number(room?.syncRevision ?? room?.revision ?? 0)), members: (Array.isArray(room.members) ? room.members : []).slice(0, GROUP_CHAT_MAX_MEMBERS).map(member => ({ @@ -302,10 +400,24 @@ export function groupChatSyncSnapshot( } function groupChatSyncEntryKey(entry: GroupMessage) { + const seq = groupChatSyncSequence(entry) + + if (seq !== null) { + return `seq:${seq}` + } + + if (entry?.eventId) { + return `event:${String(entry.eventId)}` + } + if (entry?.id) { return `id:${String(entry.id)}` } + return `fallback:${groupChatSyncFallbackKey(entry)}` +} + +function groupChatSyncFallbackKey(entry: GroupMessage) { return JSON.stringify([ Number(entry?.at || 0), String(entry?.from?.kind || ''), @@ -323,6 +435,112 @@ function groupChatSyncEntryKey(entry: GroupMessage) { ]) } +export function groupChatSyncSequence(entry: GroupMessage | null | undefined) { + const seq = Number(entry?.seq) + + return Number.isSafeInteger(seq) && seq > 0 ? seq : null +} + +function compareGroupChatSyncEntries(left: GroupMessage, right: GroupMessage) { + const leftSeq = groupChatSyncSequence(left) + const rightSeq = groupChatSyncSequence(right) + + if (leftSeq !== null && rightSeq !== null) { + return leftSeq - rightSeq || groupChatSyncEntryKey(left).localeCompare(groupChatSyncEntryKey(right)) + } + + const byTime = Number(left?.at || 0) - Number(right?.at || 0) + + return byTime || groupChatSyncEntryKey(left).localeCompare(groupChatSyncEntryKey(right)) +} + +/** Union a hosted replay with local/compact mirrors without briefly showing + * both the optimistic event id and its authoritative sequence twin. */ +export function mergeGroupChatSyncEntries(...logs: GroupMessage[][]) { + const entries: GroupMessage[] = [] + const byId = new Map() + const bySeq = new Map() + const byFallback = new Map() + + const remember = (entry: GroupMessage, index: number) => { + if (entry?.eventId) { + byId.set(`event:${String(entry.eventId)}`, index) + byId.set(`id:${String(entry.eventId)}`, index) + } + + if (entry?.id) { + byId.set(`id:${String(entry.id)}`, index) + byId.set(`event:${String(entry.id)}`, index) + } + + const seq = groupChatSyncSequence(entry) + + if (seq !== null) { + bySeq.set(seq, index) + } + + byFallback.set(groupChatSyncFallbackKey(entry), index) + } + + for (const entry of logs.flat()) { + const eventId = entry?.eventId ? `event:${String(entry.eventId)}` : '' + const id = entry?.id ? `id:${String(entry.id)}` : '' + const seq = groupChatSyncSequence(entry) + let index = eventId ? byId.get(eventId) : id ? byId.get(id) : undefined + + if (index === undefined && seq !== null) { + index = bySeq.get(seq) + } + + if (index === undefined) { + index = byFallback.get(groupChatSyncFallbackKey(entry)) + } + + if (index === undefined) { + index = entries.length + entries.push(entry) + remember(entry, index) + + continue + } + + const prior = entries[index] + const authoritativeSeq = groupChatSyncSequence(prior) ?? seq + + const merged: GroupMessage = { + ...prior, + ...entry, + ...(prior?.images && !entry?.images + ? { + images: prior.images + } + : {}), + ...(prior?.id && !entry?.id + ? { + id: prior.id + } + : {}), + ...(prior?.eventId && !entry?.eventId + ? { + eventId: prior.eventId + } + : {}), + ...(authoritativeSeq !== null + ? { + seq: authoritativeSeq + } + : {}) + } + + entries[index] = merged + remember(prior, index) + remember(entry, index) + remember(merged, index) + } + + return entries.sort(compareGroupChatSyncEntries) +} + /** Members dedupe on durable identity — the same (connectionId, name) pair * botRosterKey seats them by everywhere else. `connectionLabel` and `handle` * are display strings each machine re-derives (a connection rename, an older @@ -421,26 +639,33 @@ export function mergeGroupChatSyncSnapshots( ? Math.max(0, Number(writeRevision || 0)) : Math.max(0, Number(localRoom?.revision || 0)) - const entries = new Map() - - for (const entry of [...(remoteRoom?.log || []), ...(localRoom?.log || [])]) { - entries.set(groupChatSyncEntryKey(entry), entry) - } + const entries = mergeGroupChatSyncEntries(remoteRoom?.log || [], localRoom?.log || []) // Identity fields (display name, membership, picture) follow the higher // revision; a tie unions members and prefers the local writer's fields. let identity: GroupChatSyncRoom | undefined let members: GroupMember[] let image: null | string | undefined + let hosted: null | string | undefined + let hostedEpoch = 0 + let hostedPresent = false + const remoteHostedPresent = Object.prototype.hasOwnProperty.call(remoteRoom || {}, 'hosted') + const localHostedPresent = Object.prototype.hasOwnProperty.call(localRoom || {}, 'hosted') if (localRevision > remoteRevision) { identity = localRoom members = [...(localRoom?.members || [])] image = localRoom?.image + hostedPresent = localHostedPresent || remoteHostedPresent + hosted = localHostedPresent ? groupChatHostedGateway(localRoom) : groupChatHostedGateway(remoteRoom) + hostedEpoch = localHostedPresent ? groupChatHostedEpoch(localRoom) : groupChatHostedEpoch(remoteRoom) } else if (remoteRevision > localRevision) { identity = remoteRoom members = [...(remoteRoom?.members || [])] image = remoteRoom?.image + hostedPresent = remoteHostedPresent || localHostedPresent + hosted = remoteHostedPresent ? groupChatHostedGateway(remoteRoom) : groupChatHostedGateway(localRoom) + hostedEpoch = remoteHostedPresent ? groupChatHostedEpoch(remoteRoom) : groupChatHostedEpoch(localRoom) } else { identity = localRoom || remoteRoom const byId = new Map() @@ -451,6 +676,24 @@ export function mergeGroupChatSyncSnapshots( members = [...byId.values()] image = Object.prototype.hasOwnProperty.call(localRoom || {}, 'image') ? localRoom.image : remoteRoom?.image + hostedPresent = localHostedPresent || remoteHostedPresent + hosted = localHostedPresent ? groupChatHostedGateway(localRoom) : groupChatHostedGateway(remoteRoom) + hostedEpoch = localHostedPresent ? groupChatHostedEpoch(localRoom) : groupChatHostedEpoch(remoteRoom) + } + + // ui_meta is a display cache, not an authority receipt. Preserve any + // existing non-empty fence even if a newer legacy writer omitted it. + const remoteHosted = groupChatHostedGateway(remoteRoom) + const localHosted = groupChatHostedGateway(localRoom) + + if (localHosted) { + hostedPresent = true + hosted = localHosted + hostedEpoch = groupChatHostedEpoch(localRoom) + } else if (remoteHosted) { + hostedPresent = true + hosted = remoteHosted + hostedEpoch = groupChatHostedEpoch(remoteRoom) } rooms[key] = { @@ -464,13 +707,20 @@ export function mergeGroupChatSyncSnapshots( roomId: identity?.roomId || key.slice(3) } : {}), - log: [...entries.values()].sort((left, right) => { - const byTime = Number(left?.at || 0) - Number(right?.at || 0) - - return byTime || groupChatSyncEntryKey(left).localeCompare(groupChatSyncEntryKey(right)) - }), + log: entries, members, revision: Math.max(remoteRevision, localRevision), + ...(hostedPresent + ? { + hosted: hosted || null, + hostedEpoch: hostedEpoch || null, + continuityMode: hosted + ? identity?.continuityMode === 'distributed' + ? ('distributed' as const) + : ('gateway' as const) + : ('desktop' as const) + } + : {}), ...(typeof image === 'string' && image ? { image @@ -609,26 +859,10 @@ export function mergeRemoteGroupChatSnapshotIntoRooms( const remoteRevision = Math.max(0, Number(projected.revision || 0)) const localRevision = Math.max(0, Number(existing.syncRevision || 0)) - const entries = new Map( - (Array.isArray(existing.log) ? existing.log : []).map(entry => [groupChatSyncEntryKey(entry), entry]) - ) - const members = new Map( (Array.isArray(existing.members) ? existing.members : []).map(member => [groupChatSyncMemberKey(member), member]) ) - for (const entry of projected.log) { - const entryKey = groupChatSyncEntryKey(entry) - - // The projection is COMPACT (truncated text, no images). When the same - // entry exists locally, the local rich copy is authoritative — merging - // the compact twin over it would strip attachments and retrigger - // watermark deltas for members that already saw it (phantom rounds). - if (!entries.has(entryKey)) { - entries.set(entryKey, entry) - } - } - const isPreserved = preserved.has(displayName) || (localName && preserved.has(localName)) if (!isPreserved) { @@ -644,15 +878,15 @@ export function mergeRemoteGroupChatSnapshotIntoRooms( } } - const log = assignLegacyThreads( - [...entries.values()].sort((left, right) => { - const byTime = Number(left?.at || 0) - Number(right?.at || 0) - - return byTime || groupChatSyncEntryKey(left).localeCompare(groupChatSyncEntryKey(right)) - }) - ) + // Projection copies are compact and therefore go first: the local rich + // twin overlays them while retaining the authoritative hosted sequence. + const log = assignLegacyThreads(mergeGroupChatSyncEntries(projected.log, existing.log || [])) const bounded = trimGroupChatLog(log, existing.watermarks || {}) + const projectedHosted = groupChatHostedGateway(projected) + const existingHosted = groupChatHostedGateway(existing) + const cachedHosted = existingHosted || projectedHosted + const cachedHostedEpoch = existingHosted ? groupChatHostedEpoch(existing) : groupChatHostedEpoch(projected) // A remote rename with a higher revision moves the local record to the // new display name; local views keyed by the old name follow on the @@ -680,6 +914,13 @@ export function mergeRemoteGroupChatSnapshotIntoRooms( : remoteRevision >= localRevision && Object.prototype.hasOwnProperty.call(projected, 'image') ? projected.image || null : existing.image || null, + hosted: cachedHosted || null, + hostedEpoch: cachedHostedEpoch || null, + continuityMode: cachedHosted + ? existing.continuityMode === 'distributed' || projected.continuityMode === 'distributed' + ? 'distributed' + : 'gateway' + : 'desktop', syncRevision: isPreserved ? localRevision : Math.max(remoteRevision, localRevision), epoch: Number(existing.epoch || 0), running: Boolean(existing.running) @@ -748,6 +989,12 @@ export function durableGroupChatRooms(all: Record = $groupCha // name-keyed identity — same field updateGroupChat's inline map // already carries. roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + hosted: groupChatHostedGateway(room) || null, + hostedEpoch: groupChatHostedEpoch(room) || null, + hostedConnectionId: + typeof room.hostedConnectionId === 'string' && room.hostedConnectionId ? room.hostedConnectionId : null, + hostedSeq: Math.max(0, Number(room.hostedSeq || 0)), + continuityMode: groupChatContinuityMode(room), image: room.image || null, syncRevision: Math.max(0, Number(room.syncRevision || 0)) } @@ -1346,6 +1593,12 @@ export function updateGroupChat( members: Array.isArray(room.members) ? room.members : [], // Immutable room identity: the member-session title for new rooms. roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + hosted: groupChatHostedGateway(room) || null, + hostedEpoch: groupChatHostedEpoch(room) || null, + hostedConnectionId: + typeof room.hostedConnectionId === 'string' && room.hostedConnectionId ? room.hostedConnectionId : null, + hostedSeq: Math.max(0, Number(room.hostedSeq || 0)), + continuityMode: groupChatContinuityMode(room), // Room picture (small data URL, same normalization as bot avatars). image: room.image || null, syncRevision: Math.max(0, Number(room.syncRevision || 0)) @@ -1556,6 +1809,17 @@ export function groupThreadOf(entry: GroupMessage): string { return entry?.thread || 'legacy' } +/** Count only transcript rows a person can actually see, excluding the + * thread head itself. Status-only replay events must not inflate replies. */ +export function groupThreadReplyCount(log: GroupMessage[], thread: string): number { + const visible = (log || []).filter( + entry => + groupThreadOf(entry) === thread && (Boolean(String(entry?.text || '').trim()) || Boolean(entry?.images?.length)) + ) + + return Math.max(0, visible.length - 1) +} + export function mintGroupThreadId(): string { return `t${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` } @@ -1569,14 +1833,25 @@ export function assignLegacyThreads(log: GroupMessage[]): GroupMessage[] { let current: null | string = null let n = 0 - return (log || []).map((entry, i) => { + const normalized = (log || []).map(entry => { + const at = Number(entry?.at || 0) + + return at >= 1_000_000_000 && at < 1_000_000_000_000 + ? { + ...entry, + at: at * 1000 + } + : entry + }) + + return normalized.map((entry, i) => { if (entry?.thread) { current = null return entry } - const prev = log[i - 1] + const prev = normalized[i - 1] const lull = !prev || (entry.at || 0) - (prev.at || 0) > GROUP_THREAD_GAP_MS if (!current || (entry.from?.kind === 'user' && lull)) { diff --git a/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx b/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx new file mode 100644 index 0000000000000..dde6d3b3ab7f3 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx @@ -0,0 +1,412 @@ +import type * as HermesSdk from '@hermes/plugin-sdk' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as DataModule from './data' +import type { HostedRoomProbe } from './hosted-room-runtime' +import { translateBots } from './i18n-test-helper' +import type { RosterRow } from './types' + +const mocks = vi.hoisted(() => ({ + createAutonomousHostedGroupChat: vi.fn(), + markHostedRoomLocallyDeleted: vi.fn(), + notify: vi.fn(), + probeHostedRoomMembers: vi.fn(), + saveBotMeta: vi.fn(async (_owner: unknown, _patch: unknown) => undefined) +})) + +vi.mock('@hermes/plugin-sdk', async importOriginal => { + const original = await importOriginal() + + return { + ...original, + host: { + ...original.host, + notify: mocks.notify + }, + usePluginI18n: () => translateBots + } +}) + +vi.mock('./data', async importOriginal => { + const original = await importOriginal() + + return { + ...original, + saveBotMeta: mocks.saveBotMeta + } +}) + +vi.mock('./hosted-room-runtime', () => ({ + createAutonomousHostedGroupChat: mocks.createAutonomousHostedGroupChat, + describeHostedRoomCreationError: () => null, + markHostedRoomLocallyDeleted: mocks.markHostedRoomLocallyDeleted, + probeHostedRoomMembers: mocks.probeHostedRoomMembers +})) + +const roster: RosterRow[] = [ + { + connectionId: 'host-a', + connectionLabel: 'Studio', + name: 'research', + remoteSource: true, + sourceScoped: true, + targetProfile: 'research' + }, + { + connectionId: 'host-a', + connectionLabel: 'Studio', + name: 'builder', + remoteSource: true, + sourceScoped: true, + targetProfile: 'builder' + } +] + +const eligibleProbe: HostedRoomProbe = { + eligible: true, + capability: { + authorityId: 'install:studio', + connectionId: 'host-a', + kind: 'driver-capable', + limits: { + attachments: false, + automaticFailover: false, + crossGatewayMembers: true + }, + persistentProcess: true, + reason: null, + roomLink: null + }, + capabilities: {}, + route: { + connectionId: 'host-a', + homeConnectionId: 'host-a', + kind: 'single-gateway', + limits: { + attachments: false, + automaticFailover: false, + crossGatewayMembers: true + }, + memberConnectionIds: ['host-a', 'host-a'], + reason: null, + remoteConnectionIds: [] + }, + routes: { + 'host-a': { + connectionId: 'host-a', + mode: 'remote', + profile: 'default', + targetProfile: 'default' + } + } +} + +beforeAll(() => { + Element.prototype.scrollIntoView = () => undefined + Element.prototype.hasPointerCapture = () => false + Element.prototype.releasePointerCapture = () => undefined + Element.prototype.setPointerCapture = () => undefined +}) + +beforeEach(async () => { + vi.clearAllMocks() + mocks.probeHostedRoomMembers.mockResolvedValue(eligibleProbe) + mocks.createAutonomousHostedGroupChat.mockResolvedValue({ + authorityId: 'install:studio', + authorityEpoch: 1, + connectionId: 'host-a', + continuityMode: 'gateway' + }) + + const { $groupChats } = await import('./group-chat') + + $groupChats.set({}) +}) + +afterEach(() => { + cleanup() +}) + +async function renderSelectedGroup(rows: RosterRow[] = roster) { + const { CreateGroupChatDialog } = await import('./create-dialog') + + const Harness = () => { + const [open, setOpen] = useState(true) + + return setOpen(false)} open={open} roster={rows} /> + } + + render() + + const checkboxes = screen.getAllByRole('checkbox') + + fireEvent.click(checkboxes[0]) + fireEvent.click(checkboxes[1]) + + await waitFor(() => expect(mocks.probeHostedRoomMembers).toHaveBeenCalledTimes(1)) + + return screen.getByRole('button', { + name: 'Create Group (2)' + }) as HTMLButtonElement +} + +describe('automatic Group Chat continuity', () => { + it('shows the required empty copy when no bots exist', async () => { + const { CreateGroupChatDialog } = await import('./create-dialog') + + await act(async () => { + render( undefined} open roster={[]} />) + }) + + expect(screen.getByText('No bots yet. Create a bot first.')).toBeTruthy() + }) + + it('has no creation switch and selects hosted continuity automatically when eligible', async () => { + const create = await renderSelectedGroup() + + expect(screen.getByText('New group chat')).toBeTruthy() + expect( + screen.getByText( + 'Choose 2–6 Bots.' + ) + ).toBeTruthy() + expect(screen.queryByRole('switch')).toBeNull() + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + await waitFor(() => expect(mocks.createAutonomousHostedGroupChat).toHaveBeenCalledTimes(1)) + + const { $groupChats } = await import('./group-chat') + const created = Object.values($groupChats.get())[0] + + expect(created).toMatchObject({ + continuityMode: 'gateway', + hosted: 'install:studio', + hostedConnectionId: 'host-a', + hostedEpoch: 1 + }) + expect(mocks.notify).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('pause when Desktop closes') + }) + ) + }) + + it('keeps each selected Bot on its captured gateway when the roster refreshes during creation', async () => { + const rows: RosterRow[] = [ + { + connectionId: 'host-a', + connectionLabel: 'Studio', + name: 'research', + remoteSource: true, + sourceScoped: true, + targetProfile: 'research' + }, + { + connectionId: 'host-b', + connectionLabel: 'VPS', + name: 'builder', + remoteSource: true, + sourceScoped: true, + targetProfile: 'builder' + } + ] + + mocks.probeHostedRoomMembers.mockResolvedValue({ + ...eligibleProbe, + capabilities: { + 'host-a': eligibleProbe.capability, + 'host-b': { + ...eligibleProbe.capability, + authorityId: 'install:vps', + connectionId: 'host-b' + } + }, + route: { + ...eligibleProbe.route, + kind: 'multi-gateway', + memberConnectionIds: ['host-a', 'host-b'], + remoteConnectionIds: ['host-b'] + }, + routes: { + ...eligibleProbe.routes, + 'host-b': { + connectionId: 'host-b', + mode: 'remote', + profile: 'default', + targetProfile: 'default' + } + } + }) + mocks.createAutonomousHostedGroupChat.mockImplementation(async () => { + rows[1].connectionId = 'host-a' + rows[1].connectionLabel = 'Studio' + + return { + authorityId: 'install:studio', + authorityEpoch: 1, + connectionId: 'host-a', + continuityMode: 'distributed' + } + }) + + const create = await renderSelectedGroup(rows) + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + const { $groupChats } = await import('./group-chat') + const created = Object.values($groupChats.get())[0] + + expect(created.continuityMode).toBe('distributed') + expect(created.members).toEqual([ + expect.objectContaining({ connectionId: 'host-a', name: 'research' }), + expect.objectContaining({ connectionId: 'host-b', name: 'builder' }) + ]) + expect(mocks.createAutonomousHostedGroupChat.mock.calls[0][0].members).toEqual([ + expect.objectContaining({ + member: expect.objectContaining({ connectionId: 'host-a', name: 'research' }) + }), + expect.objectContaining({ + member: expect.objectContaining({ connectionId: 'host-b', name: 'builder' }) + }) + ]) + expect( + mocks.saveBotMeta.mock.calls.map(([owner]) => (owner as { connectionId?: string }).connectionId) + ).toEqual(['host-a', 'host-b']) + }) + + it('keeps Create disabled until the probe settles', async () => { + let settleProbe: (probe: HostedRoomProbe) => void = () => undefined + + mocks.probeHostedRoomMembers.mockImplementation( + () => + new Promise(resolve => { + settleProbe = resolve + }) + ) + + const create = await renderSelectedGroup() + + expect(create.disabled).toBe(true) + settleProbe(eligibleProbe) + await waitFor(() => expect(create.disabled).toBe(false)) + }) + + it('creates a classic Desktop Group Chat when the probe fails', async () => { + mocks.probeHostedRoomMembers.mockRejectedValue(new Error('offline')) + const create = await renderSelectedGroup() + + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + const { $groupChats } = await import('./group-chat') + + await waitFor(() => expect(Object.values($groupChats.get())).toHaveLength(1)) + const created = Object.values($groupChats.get())[0] + + expect(created.continuityMode).toBe('desktop') + expect(created.hosted ?? null).toBeNull() + expect(mocks.createAutonomousHostedGroupChat).not.toHaveBeenCalled() + }) + + it('keeps unscoped local metadata owners local in classic creation', async () => { + mocks.probeHostedRoomMembers.mockRejectedValue(new Error('unsupported')) + const create = await renderSelectedGroup([{ name: 'research' }, { name: 'builder' }]) + + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + const { $groupChats } = await import('./group-chat') + const created = Object.values($groupChats.get())[0] + + expect(created.continuityMode).toBe('desktop') + expect( + mocks.saveBotMeta.mock.calls.map(([owner]) => ({ + name: (owner as RosterRow).name, + remoteSource: Boolean((owner as RosterRow).remoteSource), + sourceScoped: Boolean((owner as RosterRow).sourceScoped) + })) + ).toEqual([ + { name: 'research', remoteSource: false, sourceScoped: false }, + { name: 'builder', remoteSource: false, sourceScoped: false } + ]) + }) + + it('falls back to Desktop and shows one concise notice when hosted creation fails', async () => { + mocks.createAutonomousHostedGroupChat.mockImplementation(async ({ name, roomId }) => { + const { updateGroupChat } = await import('./group-chat') + + updateGroupChat( + name, + room => ({ + ...room, + continuityMode: 'gateway', + hosted: 'install:studio', + hostedConnectionId: 'host-a', + hostedEpoch: 1, + roomId + }), + { sync: false } + ) + + throw new Error('device refused') + }) + const create = await renderSelectedGroup() + + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + const { $groupChats } = await import('./group-chat') + + await waitFor(() => expect(Object.values($groupChats.get())).toHaveLength(1)) + const created = Object.values($groupChats.get())[0] + const attemptedRoomId = mocks.createAutonomousHostedGroupChat.mock.calls[0][0].roomId + + expect(created.continuityMode).toBe('desktop') + expect(created.hosted ?? null).toBeNull() + expect(created.roomId).not.toBe(attemptedRoomId) + expect(mocks.markHostedRoomLocallyDeleted).toHaveBeenCalledWith(attemptedRoomId) + + const fallback = mocks.notify.mock.calls.filter(([payload]) => + String(payload?.message || '').includes('Keep Desktop open') + ) + + expect(fallback).toHaveLength(1) + expect(fallback[0][0]).toEqual( + expect.objectContaining({ + kind: 'info', + message: "Studio can't keep this Group Chat running yet. Keep Desktop open." + }) + ) + }) + + it('does not create a competing Desktop room while remote cleanup is uncertain', async () => { + mocks.createAutonomousHostedGroupChat.mockRejectedValue( + Object.assign(new Error('cleanup pending'), { fallbackSafe: false }) + ) + const create = await renderSelectedGroup() + + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + const { $groupChats } = await import('./group-chat') + + await waitFor(() => expect(screen.getByText('Could not create the Group Chat. Try again.')).toBeTruthy()) + expect(Object.values($groupChats.get())).toHaveLength(0) + expect(mocks.notify).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/group-membership-controls.test.tsx b/apps/desktop/src/plugins/hermes-bots/group-membership-controls.test.tsx new file mode 100644 index 0000000000000..b89a4fd588b57 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-membership-controls.test.tsx @@ -0,0 +1,98 @@ +import type * as HermesSdk from '@hermes/plugin-sdk' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as DataModule from './data' +import { translateBots } from './i18n-test-helper' +import type { RosterRow } from './types' + +const mocks = vi.hoisted(() => ({ + notify: vi.fn(), + saveBotMeta: vi.fn(async () => undefined) +})) + +vi.mock('@hermes/plugin-sdk', async importOriginal => { + const original = await importOriginal() + + return { + ...original, + host: { + ...original.host, + notify: mocks.notify + }, + usePluginI18n: () => translateBots + } +}) + +vi.mock('./data', async importOriginal => { + const original = await importOriginal() + + return { + ...original, + saveBotMeta: mocks.saveBotMeta + } +}) + +const bot: RosterRow = { + name: 'research' +} + +beforeEach(async () => { + vi.clearAllMocks() + const [{ $botMeta }, { $groupChats }] = await Promise.all([import('./data'), import('./group-chat')]) + + $botMeta.set({ + research: { + groups: ['Hosted', 'Classic'] + } + }) + $groupChats.set({ + Hosted: { + continuityMode: 'gateway', + hosted: 'install:studio', + hostedConnectionId: 'host-a', + hostedEpoch: 1, + log: [], + members: [bot], + roomId: 'hosted-room', + watermarks: {} + }, + Classic: { + continuityMode: 'desktop', + log: [], + members: [bot], + roomId: 'classic-room', + watermarks: {} + } + }) +}) + +afterEach(() => { + cleanup() +}) + +describe('mixed Group Chat membership controls', () => { + it('keeps unrelated classic controls available while preserving the hosted membership', async () => { + const { GroupDialog } = await import('./create-dialog') + + render( undefined} />) + + const hosted = screen.getByText('Hosted').closest('label')!.querySelector('[role="checkbox"]') as HTMLButtonElement + + const classic = screen + .getByText('Classic') + .closest('label')! + .querySelector('[role="checkbox"]') as HTMLButtonElement + + expect(hosted.disabled).toBe(true) + expect(classic.disabled).toBe(false) + expect((screen.getByRole('button', { name: 'Leave other groups' }) as HTMLButtonElement).disabled).toBe(false) + + fireEvent.click(screen.getByRole('button', { name: 'Leave other groups' })) + + expect(mocks.saveBotMeta).toHaveBeenCalledWith(bot, { + groups: ['Hosted'], + group: 'Hosted' + }) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index ff65ff2745f10..a1f8d1f8a6593 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -3,6 +3,7 @@ * @mention parse, the round-robin driver, the #93129 member holds, the stop * path, and the user send that starts it all. */ +import { host } from '@hermes/plugin-sdk' import { botFriendlyNames, botHandle, clearBotAttention, mentionNameForms, noteBotAttention } from './data' import { recordGroupActivity } from './group-activity' @@ -14,6 +15,7 @@ import { GROUP_CHAT_MAX_CONTINUATIONS, GROUP_CHAT_MAX_MESSAGES, GROUP_CHAT_MAX_ROUNDS, + groupChatHostedGateway, groupSpeakerLabel, groupThreadOf, mintGroupThreadId, @@ -23,9 +25,21 @@ import { import type { GroupChatRoom, GroupHoldStamp } from './group-chat' import { durableGroupChatMembers, groupMemberKey } from './group-membership' import { harvestStrandedGroupReply, isGroupPassText, runGroupChatMemberTurn } from './group-turns' +import { + beginHostedRoomMutation, + groupChatContinuityReady, + hostedRoomMutationIsCurrent, + sendHostedGroupChat, + stopHostedGroupChat +} from './hosted-room-runtime' +import { botsText } from './i18n' import { requestForBot } from './routing' import type { Attachment, GroupMember, GroupMessage } from './types' +function hostedConnectionName(room: null | Partial | undefined) { + return room?.members?.find(member => member.connectionLabel)?.connectionLabel || botsText().group.thisHost +} + // ── group chats: bounded round-robin coordination over a shared room log ───── // // Behavioral model (clean-room): a group conversation is ONE ordered room log @@ -425,6 +439,86 @@ export function unaddressedGroupMentions(group: string, members: GroupMember[], * falls back to the room's durable roster so a two-arg call still works. */ export async function stopGroupThread(group: string, thread: null | string, members: GroupMember[] | null = null) { const room = $groupChats.get()[group] || {} + + if (groupChatHostedGateway(room)) { + if (room.hostedStatus?.state === 'stopping') { + return + } + + const connectionName = hostedConnectionName(room) + const roomId = String(room.roomId || '') + const generation = beginHostedRoomMutation(roomId) + + updateGroupChat( + group, + current => ({ + ...current, + running: true, + hostedStatus: { + state: 'stopping', + label: botsText().group.hostedStopping + } + }), + { + sync: false + } + ) + + try { + const acknowledged = await stopHostedGroupChat(group) + + if (!hostedRoomMutationIsCurrent(roomId, generation)) { + return + } + + updateGroupChat( + group, + current => ({ + ...current, + running: !acknowledged, + hostedStatus: { + state: acknowledged ? 'stopped' : 'queued', + label: acknowledged ? botsText().group.hostedStopped : botsText().group.hostedStopQueued(connectionName) + }, + continuityIssue: acknowledged ? null : botsText().group.hostedStopQueuedHint(connectionName) + }), + { + sync: false + } + ) + } catch { + if (!hostedRoomMutationIsCurrent(roomId, generation)) { + return + } + + updateGroupChat( + group, + current => ({ + ...current, + running: false, + hostedStatus: { + state: 'offline', + label: botsText().group.hostedUnavailable(connectionName) + }, + continuityIssue: botsText().group.hostedReconnectToStop(connectionName) + }), + { + sync: false + } + ) + } + + if (hostedRoomMutationIsCurrent(roomId, generation)) { + recordGroupActivity(group, { + kind: 'stopped', + member: 'You', + thread: thread || null + }) + } + + return + } + const roster = Array.isArray(members) && members.length ? members : room.members || [] const turnName = room.turn || null @@ -968,11 +1062,39 @@ export function sendToGroupChat( ): null | string { const trimmed = String(text || '').trim() const attached = Array.isArray(images) ? images.filter((img: Attachment) => img && img.data) : [] - + const roomBeforeSend = $groupChats.get()[group] + const hosted = groupChatHostedGateway(roomBeforeSend) + const connectionName = hostedConnectionName(roomBeforeSend) if ((!trimmed && !attached.length) || !members.length) { return null } + if (hosted && roomBeforeSend?.hostedStatus?.state === 'deleted') { + return null + } + + if (!groupChatContinuityReady(roomBeforeSend)) { + updateGroupChat( + group, + current => ({ + ...current, + continuityIssue: botsText().group.hostedSyncing + }), + { sync: false } + ) + + return null + } + + if (hosted && attached.length) { + host.notify({ + kind: 'info', + message: botsText().group.hostedAttachmentsUnavailable + }) + + return null + } + const target = thread || mintGroupThreadId() $groupNeedsYou.set({ ...$groupNeedsYou.get(), @@ -998,6 +1120,80 @@ export function sendToGroupChat( attached ) + if (!sent) { + return null + } + + if (hosted) { + const roomId = String(roomBeforeSend?.roomId || '') + const generation = beginHostedRoomMutation(roomId) + + updateGroupChat( + group, + (room: GroupChatRoom) => ({ + ...room, + running: true, + hostedStatus: { + state: 'sending', + label: botsText().group.hostedSending + } + }), + { + sync: false + } + ) + recordGroupActivity(group, { + kind: 'queued', + member: 'You', + thread: target + }) + void sendHostedGroupChat(group, sent, target) + .then(acknowledged => { + if (!hostedRoomMutationIsCurrent(roomId, generation)) { + return + } + + updateGroupChat( + group, + room => ({ + ...room, + running: true, + hostedStatus: { + state: acknowledged ? 'working' : 'queued', + label: acknowledged ? botsText().group.hostedWorking : botsText().group.hostedQueued(connectionName) + }, + continuityIssue: acknowledged ? null : botsText().group.hostedQueuedHint(connectionName) + }), + { + sync: false + } + ) + }) + .catch(() => { + if (!hostedRoomMutationIsCurrent(roomId, generation)) { + return + } + + updateGroupChat( + group, + room => ({ + ...room, + running: false, + hostedStatus: { + state: 'failed', + label: botsText().group.hostedNeedsAttention + }, + continuityIssue: botsText().group.hostedSendFailed(connectionName) + }), + { + sync: false + } + ) + }) + + return target + } + const wasRunning = ($groupChats.get()[group] || {}).running === true updateGroupChat(group, (room: GroupChatRoom) => { room.epoch = (room.epoch || 0) + 1 diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts new file mode 100644 index 0000000000000..e3403a5a68d88 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts @@ -0,0 +1,506 @@ +import { describe, expect, it } from 'vitest' + +import { + classifyHostedRoomCapability, + createHostedRoomOutbox, + createHostedRoomReplayState, + deriveFriendlyHostedRoomStatus, + isHostedRoomContinuityEligible, + profileScopedRoomLinkEndpoint, + reduceHostedRoomEvents, + reduceHostedRoomOutbox, + replayHostedRoomPages, + resolveAutonomousRoomPlan, + resolveSingleGatewayRoute +} from './hosted-room-client' + +function event( + seq: number, + eventId: string, + kind: string, + payload: Record = {}, + actor: Record = { + kind: 'gateway', + id: 'install:home' + } +) { + return { + room_id: 'room-1', + seq, + event_id: eventId, + kind, + actor, + payload, + created_at: seq + } +} + +describe('hosted Group Chat capability negotiation', () => { + it('distinguishes an old gateway from a transient outage and a persistent driver', () => { + const missing = Object.assign(new Error('JSON-RPC -32601: method not found'), { + code: -32601 + }) + + const old = classifyHostedRoomCapability( + { + ok: false, + error: missing + }, + { + connectionId: 'gateway-a' + } + ) + + const offline = classifyHostedRoomCapability(new Error('socket closed during reconnect')) + + const capable = classifyHostedRoomCapability( + { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home', + max_log_limit: 250 + }, + { + connectionId: 'gateway-a' + } + ) + + expect(old).toMatchObject({ + kind: 'unsupported', + reason: 'old-gateway', + connectionId: 'gateway-a' + }) + expect(offline.kind).toBe('transient-failure') + expect(capable).toMatchObject({ + kind: 'driver-capable', + authorityId: 'install:home', + persistentProcess: true, + maxLogLimit: 250 + }) + expect(isHostedRoomContinuityEligible(capable)).toBe(true) + expect( + isHostedRoomContinuityEligible({ + driver: true, + persistent_process: false + }) + ).toBe(false) + }) + + it('offers hosted continuity only when every member resolves to one gateway', () => { + const same = resolveSingleGatewayRoute( + [ + { + name: 'research', + connectionId: 'gateway-a', + sourceScoped: true + }, + { + name: 'builder', + route: { + connectionId: 'gateway-a', + mode: 'remote', + profile: 'builder', + targetProfile: 'builder' + }, + remoteSource: true + } + ], + { + activeConnectionId: 'local' + } + ) + + const mixed = resolveSingleGatewayRoute([ + { + name: 'research', + connectionId: 'gateway-a', + sourceScoped: true + }, + { + name: 'builder', + connectionId: 'gateway-b', + sourceScoped: true + } + ]) + + const unresolved = resolveSingleGatewayRoute( + [ + { + name: 'research' + }, + { + name: 'missing', + sourceScoped: true + } + ], + { + activeConnectionId: 'local' + } + ) + + expect(same).toMatchObject({ + kind: 'single-gateway', + connectionId: 'gateway-a' + }) + expect(mixed).toMatchObject({ + kind: 'unsupported', + reason: 'cross-gateway' + }) + expect(unresolved.reason).toBe('unresolved-member-route') + }) + + it('plans a direct multi-host Group Chat only from verified v2 catalogs', () => { + const capability = (connectionId: string, installationId: string) => + classifyHostedRoomCapability( + { + authority_gateway_id: installationId, + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { + available: true, + url: `https://${connectionId}.example.test:19445` + }, + catalog: { + attachments: false, + catalog_digest: `digest-${connectionId}`, + installation_id: installationId, + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + }, + { + connectionId + } + ) + + const capabilities = { + 'host-a': capability('host-a', 'install:a'), + 'host-b': capability('host-b', 'install:b') + } + + const plan = resolveAutonomousRoomPlan( + [ + { connectionId: 'host-a', name: 'research', sourceScoped: true }, + { connectionId: 'host-b', name: 'builder', sourceScoped: true } + ], + { + activeConnectionId: 'host-a', + capabilities + } + ) + + expect(plan).toMatchObject({ + connectionId: 'host-a', + homeConnectionId: 'host-a', + kind: 'multi-gateway', + remoteConnectionIds: ['host-b'] + }) + expect(capabilities['host-b'].roomLink?.catalog?.attachments).toBe(false) + + const incompatible = { + ...capabilities, + 'host-b': classifyHostedRoomCapability( + { + authority_gateway_id: 'install:b', + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://host-b.example.test' }, + catalog: { + catalog_digest: 'digest-b', + installation_id: 'install:b', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [1], + text: true + } + } + }, + { connectionId: 'host-b' } + ) + } + + expect( + resolveAutonomousRoomPlan( + [ + { connectionId: 'host-a', name: 'research', sourceScoped: true }, + { connectionId: 'host-b', name: 'builder', sourceScoped: true } + ], + { activeConnectionId: 'host-a', capabilities: incompatible } + ) + ).toMatchObject({ + kind: 'unsupported', + reason: 'remote-needs-setup', + unavailableConnectionId: 'host-b' + }) + }) + + it('scopes one advertised endpoint to the selected Bot profile', () => { + expect(profileScopedRoomLinkEndpoint('https://peer.example.test/hermes/', 'research lead')).toBe( + 'https://peer.example.test/hermes/p/research%20lead' + ) + expect(profileScopedRoomLinkEndpoint('https://peer.example.test/hermes/p/other', 'research lead')).toBeNull() + expect(profileScopedRoomLinkEndpoint('https://peer.example.test/hermes/', 'default')).toBe( + 'https://peer.example.test/hermes' + ) + }) +}) + +describe('hosted Group Chat replay', () => { + it('normalizes gateway epoch seconds before rendering relative time', () => { + const seconds = 1_787_968_060.355 + + const replayed = reduceHostedRoomEvents(createHostedRoomReplayState({ roomId: 'room-1' }), [ + { + ...event(1, 'message-1', 'message.user', { text: 'Hello' }), + created_at: seconds + } + ]) + + expect(replayed.messages[0].at).toBe(seconds * 1000) + }) + + it('orders, deduplicates, and advances across unknown event kinds without applying them', () => { + const initial = createHostedRoomReplayState({ + roomId: 'room-1', + name: 'Release', + authorityId: 'install:home', + connectionId: 'gateway-a' + }) + + const user = event( + 1, + 'message-1', + 'message.user', + { + text: 'Start the review', + thread_id: 'thread-1' + }, + { + kind: 'user', + id: 'desktop' + } + ) + + const unknown = event(2, 'future-1', 'future.room.signal', { + destructive: true + }) + + const member = event( + 3, + 'message-2', + 'message.member', + { + text: 'Review complete', + thread_id: 'thread-1' + }, + { + kind: 'member', + id: 'research', + display_name: 'Research' + } + ) + + const replayed = reduceHostedRoomEvents(initial, [member, unknown, user, user]) + + expect(replayed.cursor).toBe(3) + expect(replayed.messages.map(message => [message.seq, message.eventId, message.text])).toEqual([ + [1, 'message-1', 'Start the review'], + [3, 'message-2', 'Review complete'] + ]) + expect(replayed.timeline.map(entry => entry.eventId)).toEqual(['message-1', 'message-2']) + expect(reduceHostedRoomEvents(replayed, [member, user]).messages).toHaveLength(2) + }) + + it('buffers gaps and never advances past missing history', () => { + const initial = createHostedRoomReplayState({ + roomId: 'room-1' + }) + + const later = event(2, 'message-2', 'message.member', { text: 'Done' }) + + const gapped = reduceHostedRoomEvents(initial, [later]) + + expect(gapped.cursor).toBe(0) + expect(gapped.messages).toEqual([]) + expect(gapped.pendingEvents).toHaveLength(1) + + const complete = reduceHostedRoomEvents(gapped, [event(1, 'message-1', 'message.user', { text: 'Go' })]) + + expect(complete.cursor).toBe(2) + expect(complete.messages.map(message => message.text)).toEqual(['Go', 'Done']) + }) + + it('pages from the persisted cursor and stops safely on a stalled gap', async () => { + const pages = [ + { + events: [event(1, 'message-1', 'message.user', { text: 'Go' })], + latest_seq: 3, + has_more: true + }, + { + events: [event(3, 'message-3', 'message.member', { text: 'Done' })], + latest_seq: 3, + has_more: false + } + ] + + const replayed = await replayHostedRoomPages({ + state: createHostedRoomReplayState({ + roomId: 'room-1' + }), + fetchPage: async () => pages.shift() + }) + + expect(replayed).toMatchObject({ + complete: false, + reason: 'stalled', + pages: 2 + }) + expect(replayed.state.cursor).toBe(1) + expect(replayed.state.pendingEvents).toHaveLength(1) + }) + + it('derives short status copy without reflecting raw provider details', () => { + const state = reduceHostedRoomEvents( + createHostedRoomReplayState({ + roomId: 'room-1', + connectionId: 'gateway-a' + }), + [ + event(1, 'failed-1', 'turn.failed', { + member_display_name: 'Builder', + reason_code: 'provider_auth_or_access', + raw_error: 'secret upstream payload' + }) + ] + ) + + const friendly = deriveFriendlyHostedRoomStatus(state) + + expect(friendly).toMatchObject({ + kind: 'needs-attention', + member: 'Builder', + reasonCode: 'provider_auth_or_access' + }) + expect(JSON.stringify(friendly)).not.toContain('secret upstream payload') + }) +}) + +describe('hosted Group Chat command outbox', () => { + const command = { + commandId: 'command-1', + kind: 'send' as const, + roomId: 'room-1', + authorityId: 'install:home', + connectionId: 'gateway-a', + payload: { + text: 'Hello', + thread_id: 'thread-1' + } + } + + it('returns an interrupted in-flight command to pending with the same idempotency key', () => { + const enqueued = reduceHostedRoomOutbox(createHostedRoomOutbox(), { + type: 'enqueue', + command + }) + + const inFlight = reduceHostedRoomOutbox(enqueued, { + type: 'dispatch', + commandId: command.commandId + }) + + const restored = createHostedRoomOutbox(inFlight) + + expect(restored.commands).toEqual([ + expect.objectContaining({ + commandId: command.commandId, + status: 'pending', + attempts: 1 + }) + ]) + }) + + it('deduplicates identical commands, rejects conflicting reuse, and drops acknowledged work', () => { + const once = reduceHostedRoomOutbox(createHostedRoomOutbox(), { + type: 'enqueue', + command + }) + + const twice = reduceHostedRoomOutbox(once, { + type: 'enqueue', + command: { + ...command, + payload: { + thread_id: 'thread-1', + text: 'Hello' + } + } + }) + + expect(twice.commands).toHaveLength(1) + expect(() => + reduceHostedRoomOutbox(twice, { + type: 'enqueue', + command: { + ...command, + payload: { + text: 'Different' + } + } + }) + ).toThrow(/different content/) + expect(() => + reduceHostedRoomOutbox(twice, { + type: 'enqueue', + command: { + ...command, + commandId: 'raw-file-command', + payload: { + content_base64: 'not-allowed-in-stage-1' + } + } + }) + ).toThrow(/cannot carry raw attachment/) + expect( + reduceHostedRoomOutbox(twice, { + type: 'acknowledge', + commandId: command.commandId + }).commands + ).toEqual([]) + }) + + it('fails closed before an offline device can grow the outbox without bound', () => { + let outbox = createHostedRoomOutbox() + + for (let index = 0; index < 256; index += 1) { + outbox = reduceHostedRoomOutbox(outbox, { + type: 'enqueue', + command: { + ...command, + commandId: `command-${index}`, + payload: { + text: `Message ${index}` + } + } + }) + } + + expect(() => + reduceHostedRoomOutbox(outbox, { + type: 'enqueue', + command: { + ...command, + commandId: 'command-overflow' + } + }) + ).toThrow(/waiting to sync/) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts new file mode 100644 index 0000000000000..b6027c3e21b95 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts @@ -0,0 +1,1220 @@ +/** + * Pure client-side contracts for gateway-hosted Group Chats. + * + * This module never talks to a gateway or mutates a room atom. It classifies + * capability probes, validates the same-gateway boundary, reduces the + * monotonic event log, and owns the serializable command outbox. Keeping those + * transitions pure makes reconnect/relaunch behavior testable independently + * from React and the plugin lifecycle. + */ + +import type { GroupMember, GroupMessageAuthor } from './types' + +const MIN_ROOM_MEMBERS = 2 +const MAX_ROOM_MEMBERS = 6 +const MAX_REPLAY_PAGE_SIZE = 500 +const MAX_REPLAY_PAGES = 100 +const FORBIDDEN_TRANSPORT_FIELD_TOKENS = new Set(['base64', 'byte', 'bytes', 'data', 'path', 'paths']) +export const ROOM_LINK_PROTOCOL_VERSION = 2 + +export interface HostedRoomClientLimitations { + attachments: boolean + automaticFailover: boolean + crossGatewayMembers: boolean +} + +export const HOSTED_ROOM_CLIENT_LIMITATIONS: HostedRoomClientLimitations = Object.freeze({ + attachments: false, + automaticFailover: false, + crossGatewayMembers: true +}) + +const MAX_HOSTED_ROOM_OUTBOX_COMMANDS = 256 + +export type HostedRoomCapabilityKind = 'driver-capable' | 'transient-failure' | 'unsupported' + +export interface HostedRoomCapability { + authorityId: null | string + connectionId: null | string + kind: HostedRoomCapabilityKind + limits: typeof HOSTED_ROOM_CLIENT_LIMITATIONS + maxLogLimit?: number + persistentProcess: boolean | null + reason: null | string + roomLink: null | RoomLinkCapability +} + +export interface RoomLinkCapability { + catalog: null | { + attachments: boolean + digest: null | string + installationId: null | string + linkModes: string[] + persistentProcess: boolean + protocolVersions: number[] + text: boolean + } + enabled: boolean + endpoint: null | string + endpointReason: null | string + profile: null | string + reason: null | string +} + +export interface AutonomousRoomPlan extends HostedRoomRouteResolution { + homeConnectionId: null | string + remoteConnectionIds: string[] + unavailableConnectionId?: string +} + +export interface HostedRoomRouteResolution { + connectionId: null | string + kind: 'multi-gateway' | 'single-gateway' | 'unsupported' + limits: typeof HOSTED_ROOM_CLIENT_LIMITATIONS + memberConnectionIds: Array + reason: null | string +} + +export interface HostedRoomEvent { + actor: Record + createdAt: number + eventId: string + kind: string + payload: Record + roomId: null | string + seq: number +} + +export interface HostedReplayMessage { + at: number + eventId: string + from: GroupMessageAuthor + seq: number + text: string + thread: string +} + +export interface HostedRoomActivity { + at: number + eventId: string + kind: string + member: string + reasonCode: null | string + seq: number +} + +export interface HostedRoomReplayState { + activity: HostedRoomActivity[] + authorityEpoch: null | number + authorityId: null | string + conflicts: Array<{ eventId: string; seq: number }> + connectionId: null | string + cursor: number + deleted: boolean + lastStatusEvent: HostedRoomEvent | null + latestSeq: number + members: Array> + messages: HostedReplayMessage[] + name: string + pendingEvents: HostedRoomEvent[] + roomId: null | string + timeline: Array<{ eventId: string; kind: string; seq: number }> +} + +export interface FriendlyHostedRoomStatus { + canRetry?: boolean + canStop?: boolean + kind: string + member?: null | string + reasonCode?: null | string +} + +export type HostedRoomCommandKind = 'create' | 'disband' | 'rename' | 'retry' | 'send' | 'stop' +export type HostedRoomCommandStatus = 'failed' | 'in-flight' | 'pending' + +export interface HostedRoomCommand { + attempts: number + authorityId: null | string + commandId: string + connectionId: string + failureCode: null | string + kind: HostedRoomCommandKind + payload: Record + roomId: string + status: HostedRoomCommandStatus +} + +export interface HostedRoomOutbox { + commands: HostedRoomCommand[] + version: 1 +} + +export type HostedRoomOutboxAction = + | { command: Partial; type: 'enqueue' } + | { commandId: string; type: 'acknowledge' | 'dispatch' | 'retry' | 'transient-failure' } + | { commandId: string; failureCode?: string; type: 'terminal-failure' } + +const STATUS_EVENT_KINDS = new Set([ + 'authority.lost', + 'member.unavailable', + 'room.activity', + 'turn.cancelled', + 'turn.deferred', + 'turn.failed', + 'turn.reassigned', + 'turn.settled', + 'turn.started' +]) + +const KNOWN_EVENT_KINDS = new Set([ + 'authority.claimed', + 'authority.lost', + 'member.unavailable', + 'message.member', + 'message.user', + 'room.activity', + 'room.created', + 'room.disbanded', + 'room.members_changed', + 'room.renamed', + 'turn.cancelled', + 'turn.deferred', + 'turn.failed', + 'turn.reassigned', + 'turn.settled', + 'turn.started' +]) + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null +} + +function text(value: unknown): null | string { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function nonNegativeInteger(value: unknown, fallback = 0): number { + const number = Number(value) + + return Number.isSafeInteger(number) && number >= 0 ? number : fallback +} + +function positiveInteger(value: unknown, fallback: null | number = null): null | number { + const number = Number(value) + + return Number.isSafeInteger(number) && number > 0 ? number : fallback +} + +function timestampMilliseconds(value: unknown) { + const number = Number(value) + + if (!Number.isFinite(number) || number <= 0) { + return 0 + } + + return number < 1_000_000_000_000 ? number * 1000 : number +} + +function errorCode(error: unknown): unknown { + const outer = record(error) + const inner = record(outer?.error) + + return outer?.code ?? inner?.code ?? null +} + +function errorMessage(error: unknown): string { + const outer = record(error) + const inner = record(outer?.error) + + return String(outer?.message || inner?.message || error || '') +} + +function isMissingCapabilityMethod(error: unknown): boolean { + return ( + errorCode(error) === -32601 || + /method not found|-32601|unknown method|no such method|no handler for|unsupported rpc/i.test(errorMessage(error)) + ) +} + +function capabilityResult(probe: unknown): Record | null { + const candidate = record(probe) + + if (!candidate) { + return null + } + + if (candidate.ok === true) { + return record(candidate.result) + } + + if ( + !Object.prototype.hasOwnProperty.call(candidate, 'ok') && + !Object.prototype.hasOwnProperty.call(candidate, 'error') + ) { + return candidate + } + + return null +} + +function roomLinkCapability(value: unknown): null | RoomLinkCapability { + const candidate = record(value) + + if (!candidate) { + return null + } + + const catalog = record(candidate.catalog) + const endpoint = record(candidate.endpoint) + + return { + enabled: candidate.enabled === true, + endpoint: endpoint?.available === true ? text(endpoint.url) : null, + endpointReason: endpoint?.available === false ? text(endpoint.reason) : null, + reason: text(candidate.reason), + profile: text(candidate.profile), + catalog: catalog + ? { + installationId: text(catalog.installation_id), + digest: text(catalog.catalog_digest), + persistentProcess: catalog.persistent_process === true, + text: catalog.text === true, + attachments: catalog.attachments === true, + linkModes: Array.isArray(catalog.link_modes) ? catalog.link_modes.map(String).filter(Boolean) : [], + protocolVersions: Array.isArray(catalog.protocol_versions) + ? catalog.protocol_versions.map(Number).filter(Number.isSafeInteger) + : [] + } + : null + } +} + +/** A missing RPC is a compatibility verdict; a socket failure is not. */ +export function classifyHostedRoomCapability( + probe: unknown, + { connectionId = null }: { connectionId?: null | string } = {} +): HostedRoomCapability { + const candidate = record(probe) + const error = probe instanceof Error ? probe : candidate?.ok === false ? candidate.error || probe : candidate?.error + const localConnectionId = text(connectionId) + + if (error) { + const unsupported = isMissingCapabilityMethod(error) + + return { + kind: unsupported ? 'unsupported' : 'transient-failure', + reason: unsupported ? 'old-gateway' : 'probe-failed', + connectionId: localConnectionId, + authorityId: null, + persistentProcess: null, + roomLink: null, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const capabilities = capabilityResult(probe) + + if (!capabilities) { + return { + kind: 'transient-failure', + reason: 'invalid-response', + connectionId: localConnectionId, + authorityId: null, + persistentProcess: null, + roomLink: null, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + if (capabilities.driver !== true) { + return { + kind: 'unsupported', + reason: capabilities.driver === false ? 'driver-disabled' : 'incomplete-contract', + connectionId: localConnectionId, + authorityId: null, + persistentProcess: capabilities.persistent_process === true, + roomLink: roomLinkCapability(capabilities.room_link), + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const authorityId = text(capabilities.authority_gateway_id) + + if (!authorityId) { + return { + kind: 'unsupported', + reason: 'incomplete-contract', + connectionId: localConnectionId, + authorityId: null, + persistentProcess: capabilities.persistent_process === true, + roomLink: roomLinkCapability(capabilities.room_link), + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + return { + kind: 'driver-capable', + reason: null, + connectionId: localConnectionId, + authorityId, + persistentProcess: capabilities.persistent_process === true, + roomLink: roomLinkCapability(capabilities.room_link), + maxLogLimit: positiveInteger(capabilities.max_log_limit, 100) || 100, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } +} + +export function isHostedRoomContinuityEligible(capability: unknown): boolean { + const candidate = record(capability) + + if (!candidate) { + return false + } + + if (Object.prototype.hasOwnProperty.call(candidate, 'kind')) { + return candidate.kind === 'driver-capable' && candidate.persistentProcess === true + } + + return candidate.driver === true && candidate.persistent_process === true +} + +function memberConnectionId(member: GroupMember, activeConnectionId: null | string): null | string { + if (!member || member.sourceMissing) { + return null + } + + const explicit = text(member.route?.connectionId) || text(member.connectionId) + + if (explicit) { + return explicit + } + + if (member.sourceScoped || member.remoteSource) { + return null + } + + return text(activeConnectionId) +} + +export function resolveSingleGatewayRoute( + members: GroupMember[], + { activeConnectionId = null }: { activeConnectionId?: null | string } = {} +): HostedRoomRouteResolution { + const roster = Array.isArray(members) ? members : [] + + if (roster.length < MIN_ROOM_MEMBERS || roster.length > MAX_ROOM_MEMBERS) { + return { + kind: 'unsupported', + reason: 'member-count', + connectionId: null, + memberConnectionIds: [], + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const memberConnectionIds = roster.map(member => memberConnectionId(member, activeConnectionId)) + + if (memberConnectionIds.some(connectionId => !connectionId)) { + return { + kind: 'unsupported', + reason: 'unresolved-member-route', + connectionId: null, + memberConnectionIds, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const distinct = new Set(memberConnectionIds) + + if (distinct.size !== 1) { + return { + kind: 'unsupported', + reason: 'cross-gateway', + connectionId: null, + memberConnectionIds, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + return { + kind: 'single-gateway', + reason: null, + connectionId: memberConnectionIds[0], + memberConnectionIds, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } +} + +/** Choose the simplest autonomous plan without widening any gateway's + * advertised capability. */ +export function resolveAutonomousRoomPlan( + members: GroupMember[], + { + activeConnectionId = null, + capabilities = {} + }: { + activeConnectionId?: null | string + capabilities?: Record + } = {} +): AutonomousRoomPlan { + const roster = Array.isArray(members) ? members : [] + + const route = resolveSingleGatewayRoute(roster, { + activeConnectionId + }) + + if (route.reason && route.reason !== 'cross-gateway') { + return { + ...route, + homeConnectionId: null, + remoteConnectionIds: [] + } + } + + const memberConnectionIds = roster.map(member => memberConnectionId(member, activeConnectionId)) + const connectionIds = [...new Set(memberConnectionIds.filter((value): value is string => Boolean(value)))] + + const homeCandidates = connectionIds.filter(connectionId => { + const capability = capabilities[connectionId] + + if (capability?.kind !== 'driver-capable' || capability.persistentProcess !== true) { + return false + } + + if (connectionIds.length === 1) { + return true + } + + const roomLink = capability.roomLink + + return Boolean( + roomLink?.enabled === true && + roomLink.catalog?.persistentProcess === true && + roomLink.catalog.protocolVersions.includes(ROOM_LINK_PROTOCOL_VERSION) && + roomLink.catalog.linkModes.includes('direct') + ) + }) + + const preferredHome = text(activeConnectionId) + + const homeConnectionId = + preferredHome && homeCandidates.includes(preferredHome) ? preferredHome : homeCandidates[0] || null + + if (!homeConnectionId) { + return { + kind: 'unsupported', + reason: 'no-persistent-home', + connectionId: null, + homeConnectionId: null, + memberConnectionIds, + remoteConnectionIds: connectionIds, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const remoteConnectionIds = connectionIds.filter(connectionId => connectionId !== homeConnectionId) + + const unsupportedRemote = remoteConnectionIds.find(connectionId => { + const roomLink = capabilities[connectionId]?.roomLink + + return !( + roomLink?.enabled === true && + roomLink.catalog?.persistentProcess === true && + roomLink.catalog.text === true && + roomLink.catalog.installationId && + roomLink.catalog.digest && + roomLink.catalog.protocolVersions.includes(ROOM_LINK_PROTOCOL_VERSION) && + roomLink.catalog.linkModes.includes('direct') + ) + }) + + if (unsupportedRemote) { + return { + kind: 'unsupported', + reason: 'remote-needs-setup', + connectionId: null, + homeConnectionId, + memberConnectionIds, + remoteConnectionIds, + unavailableConnectionId: unsupportedRemote, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const unreachableRemote = remoteConnectionIds.find(connectionId => !capabilities[connectionId]?.roomLink?.endpoint) + + if (unreachableRemote) { + return { + kind: 'unsupported', + reason: 'remote-needs-address', + connectionId: null, + homeConnectionId, + memberConnectionIds, + remoteConnectionIds, + unavailableConnectionId: unreachableRemote, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + return { + kind: remoteConnectionIds.length ? 'multi-gateway' : 'single-gateway', + reason: null, + connectionId: homeConnectionId, + homeConnectionId, + memberConnectionIds, + remoteConnectionIds, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } +} + +export function describeAutonomousRoomPlan( + plan: AutonomousRoomPlan, + { homeLabel = 'one host', unavailableLabel = 'One host' } = {} +) { + if (plan.kind === 'multi-gateway') { + return { + defaultEnabled: true, + level: 'distributed' as const, + title: 'Works without Desktop', + description: 'Bots can continue while Desktop is closed.' + } + } + + if (plan.kind === 'single-gateway') { + return { + defaultEnabled: true, + level: 'gateway' as const, + title: 'Works without Desktop', + description: 'Bots can continue while Desktop is closed.' + } + } + + const needsSetup = ['remote-needs-address', 'remote-needs-setup'].includes(String(plan.reason || '')) + + return { + defaultEnabled: false, + level: 'desktop' as const, + title: 'Keep Desktop open', + description: needsSetup + ? `${unavailableLabel} can't keep this Group Chat running yet.` + : 'Bots pause when Desktop closes.' + } +} + +export function profileScopedRoomLinkEndpoint(endpoint: unknown, profile: unknown) { + const base = text(endpoint)?.replace(/\/+$/, '') || null + const targetProfile = text(profile) + + if (!base || !targetProfile || targetProfile === 'default') { + return base + } + + const suffix = `/p/${encodeURIComponent(targetProfile)}` + + if (base.endsWith(suffix)) { + return base + } + + if (/\/p\/[^/]+$/i.test(base)) { + return null + } + + return `${base}${suffix}` +} + +export function describeHostedRoomCreationError(error: unknown) { + const message = errorMessage(error) + + if (/unreachable|name or service not known|timed? ?out|connection refused|network is unreachable/i.test(message)) { + return 'One Bot host cannot reach another. Check that both are online, then try again.' + } + + if (/non-json|authorization|grant|renewal|http 40[13]|unknown or unconfigured profile/i.test(message)) { + return 'One Bot host could not verify this Group Chat. Update or reconnect it, then try again.' + } + + if (/capability catalog changed|changed during setup/i.test(message)) { + return 'A Bot host changed while the Group Chat was being created. Wait for it to reconnect, then try again.' + } + + return null +} + +function cloneRecords(value: unknown): Array> { + return Array.isArray(value) ? value.map(item => ({ ...(record(item) || {}) })) : [] +} + +export function createHostedRoomReplayState( + input: Partial & { roomId?: null | string } = {} +): HostedRoomReplayState { + const cursor = nonNegativeInteger(input.cursor) + + return { + roomId: text(input.roomId), + name: typeof input.name === 'string' ? input.name : '', + members: cloneRecords(input.members), + authorityId: text(input.authorityId), + authorityEpoch: positiveInteger(input.authorityEpoch), + connectionId: text(input.connectionId), + cursor, + latestSeq: Math.max(cursor, nonNegativeInteger(input.latestSeq, cursor)), + messages: Array.isArray(input.messages) ? input.messages.map(message => ({ ...message })) : [], + activity: Array.isArray(input.activity) ? input.activity.map(entry => ({ ...entry })) : [], + timeline: Array.isArray(input.timeline) ? input.timeline.map(entry => ({ ...entry })) : [], + pendingEvents: Array.isArray(input.pendingEvents) ? input.pendingEvents.map(entry => ({ ...entry })) : [], + lastStatusEvent: input.lastStatusEvent ? { ...input.lastStatusEvent } : null, + deleted: Boolean(input.deleted), + conflicts: Array.isArray(input.conflicts) ? input.conflicts.map(conflict => ({ ...conflict })) : [] + } +} + +function normalizeEvent(raw: unknown): HostedRoomEvent | null { + const candidate = record(raw) + + if (!candidate) { + return null + } + + const seq = positiveInteger(candidate.seq) + const eventId = text(candidate.event_id) || text(candidate.eventId) + const kind = text(candidate.kind) + + if (!seq || !eventId || !kind) { + return null + } + + return { + roomId: text(candidate.room_id) || text(candidate.roomId), + seq, + eventId, + kind, + actor: { ...(record(candidate.actor) || {}) }, + payload: { ...(record(candidate.payload) || {}) }, + createdAt: timestampMilliseconds(candidate.created_at ?? candidate.createdAt) + } +} + +function memberLabel(event: HostedRoomEvent): string { + return ( + text(event.payload.member_display_name) || + text(event.payload.member_name) || + text(event.payload.display_name) || + text(event.actor.display_name) || + text(event.actor.profile) || + '' + ) +} + +function messageFromEvent(event: HostedRoomEvent): HostedReplayMessage { + const user = event.kind === 'message.user' + + return { + seq: event.seq, + eventId: event.eventId, + from: { + kind: user ? 'user' : 'member', + name: user ? 'You' : memberLabel(event), + ...(text(event.actor.connection_id) ? { source: text(event.actor.connection_id) || undefined } : {}) + }, + text: typeof event.payload.text === 'string' ? event.payload.text : '', + thread: text(event.payload.thread_id) || text(event.payload.thread) || 'legacy', + at: event.createdAt + } +} + +function applyReplayEvent(state: HostedRoomReplayState, event: HostedRoomEvent): void { + if (KNOWN_EVENT_KINDS.has(event.kind)) { + state.timeline.push({ + seq: event.seq, + eventId: event.eventId, + kind: event.kind + }) + } + + if (event.kind === 'message.user' || event.kind === 'message.member') { + state.messages.push(messageFromEvent(event)) + } else if (event.kind === 'room.created') { + state.name = text(event.payload.name) || state.name + + if (Array.isArray(event.payload.members)) { + state.members = cloneRecords(event.payload.members) + } + } else if (event.kind === 'room.renamed') { + state.name = text(event.payload.name) || state.name + } else if (event.kind === 'room.members_changed' && Array.isArray(event.payload.members)) { + state.members = cloneRecords(event.payload.members) + } else if (event.kind === 'room.disbanded') { + state.deleted = true + } else if (event.kind === 'authority.claimed') { + const authorityId = text(event.payload.authority_gateway_id) + + if (authorityId) { + if (state.authorityId && state.authorityId !== authorityId) { + state.connectionId = null + } + + state.authorityId = authorityId + } + + state.authorityEpoch = positiveInteger(event.payload.authority_epoch, state.authorityEpoch) + } else if (event.kind === 'authority.lost') { + state.connectionId = null + } + + if (STATUS_EVENT_KINDS.has(event.kind)) { + state.activity.push({ + seq: event.seq, + eventId: event.eventId, + kind: event.kind, + member: memberLabel(event), + reasonCode: text(event.payload.reason_code), + at: event.createdAt + }) + state.lastStatusEvent = event + } +} + +export function reduceHostedRoomEvents(state: HostedRoomReplayState, incomingEvents: unknown[]): HostedRoomReplayState { + const next = createHostedRoomReplayState(state) + const bySeq = new Map() + const byId = new Map() + + for (const candidate of [...next.pendingEvents, ...(Array.isArray(incomingEvents) ? incomingEvents : [])]) { + const event = normalizeEvent(candidate) + + if (!event || event.seq <= next.cursor || (next.roomId && event.roomId && next.roomId !== event.roomId)) { + continue + } + + const prior = bySeq.get(event.seq) || byId.get(event.eventId) + + if (prior) { + if (prior.seq !== event.seq || prior.eventId !== event.eventId) { + next.conflicts.push({ + seq: event.seq, + eventId: event.eventId + }) + } + + continue + } + + bySeq.set(event.seq, event) + byId.set(event.eventId, event) + } + + const pending = [...bySeq.values()].sort( + (left, right) => left.seq - right.seq || left.eventId.localeCompare(right.eventId) + ) + + next.latestSeq = Math.max(next.latestSeq, ...pending.map(event => event.seq), next.cursor) + + while (pending.length && pending[0].seq === next.cursor + 1) { + const event = pending.shift() + + if (!event) { + break + } + + applyReplayEvent(next, event) + next.cursor = event.seq + } + + next.pendingEvents = pending + + return next +} + +function status( + kind: string, + options: Pick = {} +): FriendlyHostedRoomStatus { + return { + kind, + ...options + } +} + +export function deriveFriendlyHostedRoomStatus(state: HostedRoomReplayState): FriendlyHostedRoomStatus { + if (state.deleted) { + return status('deleted') + } + + if (state.authorityId && !state.connectionId) { + return status('offline', { + canRetry: true + }) + } + + const event = state.lastStatusEvent + + if (!event) { + return status('ready') + } + + const member = memberLabel(event) + + if (event.kind === 'turn.started' || event.kind === 'turn.reassigned') { + return status('working', { + member, + canStop: true + }) + } + + if (event.kind === 'member.unavailable') { + return status('member-unavailable', { + member, + canRetry: true + }) + } + + if (event.kind === 'turn.failed') { + const reason = text(event.payload.reason_code) + + const needsAttention = [ + 'provider_auth_or_access', + 'provider_quota_limit', + 'missing_config', + 'agent_blocked' + ].includes(reason || '') + + return status(needsAttention ? 'needs-attention' : 'failed', { + member, + reasonCode: reason, + canRetry: !needsAttention + }) + } + + if (event.kind === 'turn.deferred') { + return status('waiting', { + member, + canRetry: true + }) + } + + if (event.kind === 'turn.cancelled') { + return status('stopped') + } + + if (event.kind === 'turn.settled') { + return status('ready') + } + + if (event.kind === 'room.activity') { + const activity = text(event.payload.status)?.toLowerCase() + + if (activity === 'working') { + return status('working', { + member, + canStop: true + }) + } + + if (activity === 'needs_user' || activity === 'waiting_for_user') { + return status('needs-you') + } + } + + return status('ready') +} + +export async function replayHostedRoomPages({ + state, + fetchPage, + pageSize = 100, + maxPages = 20 +}: { + fetchPage: (request: { limit: number; sinceSeq: number }) => Promise + maxPages?: number + pageSize?: number + state: HostedRoomReplayState +}) { + const limit = Math.min(MAX_REPLAY_PAGE_SIZE, Math.max(1, positiveInteger(pageSize, 100) || 100)) + const pageBound = Math.min(MAX_REPLAY_PAGES, Math.max(1, positiveInteger(maxPages, 20) || 20)) + let next = createHostedRoomReplayState(state) + let pages = 0 + let fetchedEvents = 0 + + while (pages < pageBound) { + const beforeCursor = next.cursor + let rawPage: unknown + + try { + rawPage = await fetchPage({ + sinceSeq: beforeCursor, + limit + }) + } catch (error) { + return { + state: next, + complete: false, + reason: 'transient-failure', + pages, + fetchedEvents, + error + } + } + + const page = record(rawPage) + + if (!page) { + return { + state: next, + complete: false, + reason: 'invalid-response', + pages, + fetchedEvents + } + } + + const events = Array.isArray(page.events) ? page.events : [] + const latestSeq = nonNegativeInteger(page.latest_seq ?? page.latestSeq, next.latestSeq) + + if (events.length > limit) { + return { + state: next, + complete: false, + reason: 'oversized-page', + pages, + fetchedEvents + } + } + + pages += 1 + fetchedEvents += events.length + next = reduceHostedRoomEvents(next, events) + next.latestSeq = Math.max(next.latestSeq, latestSeq) + + const hasMore = page.has_more === true || next.cursor < latestSeq + + if (!hasMore) { + return { + state: next, + complete: next.pendingEvents.length === 0, + reason: next.pendingEvents.length ? 'gap' : null, + pages, + fetchedEvents + } + } + + if (next.cursor <= beforeCursor) { + return { + state: next, + complete: false, + reason: 'stalled', + pages, + fetchedEvents + } + } + } + + return { + state: next, + complete: false, + reason: 'limit', + pages, + fetchedEvents + } +} + +function jsonRecord(value: unknown, label: string): Record { + let cloned: unknown + + try { + cloned = JSON.parse(JSON.stringify(value ?? {})) as unknown + } catch (error) { + throw new TypeError(`${label} must be JSON-serializable`, { + cause: error + }) + } + + const result = record(cloned) || {} + + assertNoRawTransportFields(result) + + return result +} + +function fieldTokens(field: string) { + return String(field) + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) +} + +function assertNoRawTransportFields(value: unknown, location = 'payload'): void { + if (!value || typeof value !== 'object') { + return + } + + if (Array.isArray(value)) { + value.forEach((entry, index) => assertNoRawTransportFields(entry, `${location}[${index}]`)) + + return + } + + for (const [field, nested] of Object.entries(value)) { + const forbidden = fieldTokens(field).find(token => FORBIDDEN_TRANSPORT_FIELD_TOKENS.has(token)) + + if (forbidden) { + throw new TypeError(`${location}.${field} cannot carry raw attachment ${forbidden}`) + } + + assertNoRawTransportFields(nested, `${location}.${field}`) + } +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(',')}]` + } + + if (value && typeof value === 'object') { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => `${JSON.stringify(key)}:${stableJson(nested)}`) + .join(',')}}` + } + + return JSON.stringify(value) +} + +function normalizeCommand(raw: Partial): HostedRoomCommand { + const commandId = text(raw.commandId) + const kind = text(raw.kind) + const roomId = text(raw.roomId) + const connectionId = text(raw.connectionId) + + if ( + !commandId || + !kind || + !['create', 'disband', 'rename', 'retry', 'send', 'stop'].includes(kind) || + !roomId || + !connectionId + ) { + throw new TypeError('hosted room command is incomplete') + } + + return { + commandId, + kind: kind as HostedRoomCommandKind, + roomId, + authorityId: text(raw.authorityId), + connectionId, + payload: jsonRecord(raw.payload, 'command payload'), + status: ['failed', 'in-flight', 'pending'].includes(String(raw.status)) + ? (raw.status as HostedRoomCommandStatus) + : 'pending', + attempts: nonNegativeInteger(raw.attempts), + failureCode: text(raw.failureCode) + } +} + +function commandSignature(command: HostedRoomCommand): string { + return stableJson({ + commandId: command.commandId, + kind: command.kind, + roomId: command.roomId, + authorityId: command.authorityId, + connectionId: command.connectionId, + payload: command.payload + }) +} + +export function createHostedRoomOutbox(persisted: unknown = null): HostedRoomOutbox { + const candidate = record(persisted) + const commands: HostedRoomCommand[] = [] + + for (const raw of Array.isArray(candidate?.commands) ? candidate.commands : []) { + const command = normalizeCommand((record(raw) || {}) as Partial) + const existing = commands.find(entry => entry.commandId === command.commandId) + + command.status = command.status === 'in-flight' ? 'pending' : command.status + + if (!existing) { + commands.push(command) + } else if (commandSignature(existing) !== commandSignature(command)) { + throw new TypeError(`commandId ${command.commandId} has conflicting persisted content`) + } + } + + return { + version: 1, + commands + } +} + +export function reduceHostedRoomOutbox(state: HostedRoomOutbox, action: HostedRoomOutboxAction): HostedRoomOutbox { + const current = state && Array.isArray(state.commands) ? state : createHostedRoomOutbox() + + if (action.type === 'enqueue') { + const command = normalizeCommand(action.command) + const existing = current.commands.find(entry => entry.commandId === command.commandId) + + command.status = 'pending' + + if (existing) { + if (commandSignature(existing) !== commandSignature(command)) { + throw new TypeError(`commandId ${command.commandId} is already bound to different content`) + } + + return current + } + + if (current.commands.length >= MAX_HOSTED_ROOM_OUTBOX_COMMANDS) { + throw new TypeError( + 'Too many Group Chat changes are waiting to sync. Reconnect the affected device and try again.' + ) + } + + return { + ...current, + commands: [...current.commands, command] + } + } + + const commandId = text(action.commandId) + + if (!commandId) { + throw new TypeError('outbox action requires commandId') + } + + if (action.type === 'acknowledge') { + return { + ...current, + commands: current.commands.filter(command => command.commandId !== commandId) + } + } + + return { + ...current, + commands: current.commands.map(command => { + if (command.commandId !== commandId) { + return command + } + + if (action.type === 'dispatch') { + return { + ...command, + status: 'in-flight' as const, + attempts: command.attempts + 1, + failureCode: null + } + } + + if (action.type === 'terminal-failure') { + return { + ...command, + status: 'failed' as const, + failureCode: text(action.failureCode) || 'command-failed' + } + } + + return { + ...command, + status: 'pending' as const + } + }) + } +} diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts new file mode 100644 index 0000000000000..d1e91cf701369 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts @@ -0,0 +1,1685 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as groupChat from './group-chat' +import type * as groupRounds from './group-rounds' +import { pluginSdkMock, scriptedStorage } from './group-test-utils' +import type * as hostedRuntime from './hosted-room-runtime' +import type { GroupChat, GroupMember } from './types' + +const { host } = vi.hoisted(() => ({ + host: {} as Record +})) + +vi.mock('@hermes/plugin-sdk', async () => pluginSdkMock(host)) + +interface RpcCall { + connectionId?: string + method: string + params: Record +} + +interface RuntimeRoom { + chat: typeof groupChat + calls: RpcCall[] + rounds: typeof groupRounds + runtime: typeof hostedRuntime + storage: Map +} + +const MEMBERS: GroupMember[] = [ + { + name: 'research', + connectionId: 'gateway-a', + sourceScoped: true, + targetProfile: 'research' + }, + { + name: 'builder', + connectionId: 'gateway-a', + sourceScoped: true, + targetProfile: 'builder' + } +] + +function room(overrides: Partial = {}): GroupChat { + return { + log: [], + watermarks: {}, + members: MEMBERS, + roomId: 'room-1', + hosted: 'install:home', + hostedEpoch: 1, + hostedConnectionId: 'gateway-a', + hostedSeq: 0, + continuityMode: 'gateway', + ...overrides + } +} + +function hostedEvent( + seq: number, + eventId: string, + kind: string, + payload: Record = {}, + actor: Record = { + kind: 'gateway', + id: 'install:home' + } +) { + return { + room_id: 'room-1', + seq, + event_id: eventId, + kind, + actor, + payload, + created_at: seq + } +} + +async function loadRuntime( + handler: ( + method: string, + params: Record, + route?: Record + ) => Promise | unknown, + routes: Array> = [ + { + connectionId: 'gateway-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'default' + } + ] +): Promise { + vi.resetModules() + const calls: RpcCall[] = [] + const storage = new Map() + + for (const key of Object.keys(host)) { + delete host[key] + } + + Object.assign(host, { + activeConnectionId: () => 'gateway-a', + notify: vi.fn(), + profileRoutes: async () => routes, + request: async (method: string, params: Record) => { + calls.push({ + method, + params + }) + + return handler(method, params) + }, + requestProfile: async (route: Record, method: string, params: Record) => { + calls.push({ + connectionId: String(route?.connectionId || ''), + method, + params + }) + + return handler(method, params, route) + }, + state: { + connectionId: { + get: () => 'gateway-a', + listen: () => () => undefined + }, + gateway: { + get: () => 'open', + listen: () => () => undefined + }, + profile: { + get: () => 'default', + listen: () => () => undefined + } + } + }) + + const [chat, rounds, runtime, shared] = await Promise.all([ + import('./group-chat'), + import('./group-rounds'), + import('./hosted-room-runtime'), + import('./shared') + ]) + + shared.setPluginCtx(scriptedStorage(storage)) + + return { + chat, + calls, + rounds, + runtime, + storage + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +describe('hosted Group Chat runtime', () => { + it('hydrates after local state, reconciles optimistic ids, and replays one contiguous gateway log', async () => { + const events = [ + hostedEvent(1, 'created-1', 'room.created', { + name: 'Release', + members: [ + { + member_id: 'research', + profile: 'research' + }, + { + member_id: 'builder', + profile: 'builder' + } + ] + }), + hostedEvent( + 2, + 'user-1', + 'message.user', + { + text: 'Start', + thread_id: 'thread-1' + }, + { + kind: 'user', + id: 'desktop' + } + ), + hostedEvent( + 3, + 'member-1', + 'message.member', + { + text: 'Done', + thread_id: 'thread-1' + }, + { + kind: 'member', + id: 'builder', + display_name: 'Builder' + } + ), + hostedEvent(4, 'settled-1', 'turn.settled') + ] + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home', + max_log_limit: 100 + } + } + + if (method === 'groups.list') { + return { + rooms: [ + { + room_id: 'room-1', + name: 'Release', + members: MEMBERS, + authority_gateway_id: 'install:home', + authority_epoch: 1, + disbanded_at: null + } + ] + } + } + + if (method === 'groups.state') { + return { + room: { + room_id: 'room-1', + name: 'Release', + members: [ + { + member_id: 'research', + profile: 'research' + }, + { + member_id: 'builder', + profile: 'builder' + } + ], + authority_gateway_id: 'install:home', + authority_epoch: 1, + disbanded_at: null + }, + driver_status: { + working: false + } + } + } + + if (method === 'groups.log') { + return { + events, + latest_seq: 4, + has_more: false + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room({ + log: [ + { + at: 2, + from: { + kind: 'user', + name: 'You' + }, + id: 'user-1', + text: 'Start', + thread: 'thread-1' + } + ] + }) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const hydrated = loaded.chat.$groupChats.get().Release + + expect(hydrated.continuityMode).toBe('gateway') + expect(hydrated.hostedSeq).toBe(4) + expect(hydrated.log.map(entry => [entry.seq, entry.id, entry.text])).toEqual([ + [2, 'user-1', 'Start'], + [3, 'member-1', 'Done'] + ]) + expect(hydrated.log.filter(entry => entry.id === 'user-1')).toHaveLength(1) + expect(hydrated.running).toBe(false) + expect(hydrated.hostedStatus).toMatchObject({ + state: 'ready', + label: 'Ready' + }) + expect(loaded.storage.get('group-chats')).toBeTruthy() + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('replays the final events of a known room before marking a remote disband', async () => { + const events = [ + hostedEvent(3, 'member-1', 'message.member', { + text: 'Finished while Desktop was closed', + thread_id: 'thread-1' + }, { + kind: 'member', + id: 'builder', + display_name: 'Builder' + }), + hostedEvent(4, 'disbanded-1', 'room.disbanded') + ] + + const loaded = await loadRuntime((method, params) => { + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:home', + driver: true, + max_log_limit: 1, + persistent_process: true + } + } + + if (method === 'groups.list') { + return { + rooms: [{ + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + }] + } + } + + if (method === 'groups.state') { + expect(params.include_disbanded).toBe(true) + + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + expect(params.include_disbanded).toBe(true) + expect(params.limit).toBe(1) + const since = Number(params.since_seq) + + return { + events: events.filter(event => event.seq > since).slice(0, 1), + has_more: since < 3, + latest_seq: 4 + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room({ + hostedSeq: 2, + log: [{ + at: 2, + from: { kind: 'user', name: 'You' }, + id: 'user-1', + seq: 2, + text: 'Start', + thread: 'thread-1' + }] + }) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.chat.$groupChats.get().Release).toMatchObject({ + hostedSeq: 4, + hostedStatus: { state: 'deleted' }, + continuityIssue: 'Delete it here to remove its local membership and history.' + }) + expect(loaded.chat.$groupChats.get().Release.log.map(entry => entry.text)).toEqual([ + 'Start', + 'Finished while Desktop was closed' + ]) + loaded.runtime.stopHostedRoomRuntime() + }) + + it.each(['state', 'log'] as const)( + 'does not paint a remote disband before terminal %s recovery succeeds', + async failurePoint => { + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { authority_gateway_id: 'install:home', driver: true, persistent_process: true } + } + + if (method === 'groups.list') { + return { + rooms: [{ + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + }] + } + } + + if (method === 'groups.state') { + if (failurePoint === 'state') { + throw new Error('temporary state failure') + } + + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + throw new Error('temporary log failure') + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room({ + hostedSeq: 2, + log: [{ + at: 2, + from: { kind: 'user', name: 'You' }, + id: 'user-1', + seq: 2, + text: 'Start', + thread: 'thread-1' + }] + }) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const unresolved = loaded.chat.$groupChats.get().Release + + expect(unresolved.hostedSeq).toBe(2) + expect(unresolved.hostedStatus?.state).not.toBe('deleted') + expect(unresolved.log.map(entry => entry.text)).toEqual(['Start']) + loaded.runtime.stopHostedRoomRuntime() + } + ) + + it('does not materialize a remotely disbanded room that this client never joined', async () => { + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { authority_gateway_id: 'install:home', driver: true, persistent_process: true } + } + + if (method === 'groups.list') { + return { + rooms: [{ + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + }] + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.chat.$groupChats.get()).toEqual({}) + expect(loaded.calls.some(call => call.method === 'groups.state')).toBe(false) + expect(loaded.calls.some(call => call.method === 'groups.log')).toBe(false) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('reconciles peer members without rewriting them onto the home gateway', async () => { + const routes = [ + { connectionId: 'gateway-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'gateway-b', mode: 'remote' as const, profile: 'default', targetProfile: 'default' } + ] + + const serverMembers = [ + { + handle: 'research', + member_id: 'member-1-research', + profile: 'research', + target: { kind: 'local', profile: 'research' } + }, + { + handle: 'builder', + member_id: 'member-2-builder', + profile: 'builder', + target: { + installation_id: 'install:gateway-b', + kind: 'peer', + peer_id: 'install:gateway-b', + profile: 'builder' + } + } + ] + + const loaded = await loadRuntime((method, _params, route) => { + const connectionId = String(route?.connectionId || '') + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: `install:${connectionId}`, + driver: true, + persistent_process: true + } + } + + if (method === 'groups.list') { + return { + rooms: + connectionId === 'gateway-a' + ? [ + { + authority_epoch: 1, + authority_gateway_id: 'install:gateway-a', + disbanded_at: null, + members: serverMembers, + name: 'Distributed', + room_id: 'room-1' + } + ] + : [] + } + } + + if (method === 'groups.state') { + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:gateway-a', + disbanded_at: null, + members: serverMembers, + name: 'Distributed', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + return { events: [], has_more: false, latest_seq: 0 } + } + + throw new Error(`unexpected method: ${method}`) + }, routes) + + loaded.chat.$groupChats.set({ + Distributed: room({ + continuityMode: 'distributed', + members: [ + { connectionId: 'gateway-a', handle: 'research', name: 'research', sourceScoped: true }, + { connectionId: 'gateway-b', handle: 'builder', name: 'builder', sourceScoped: true } + ] + }) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const reconciled = loaded.chat.$groupChats.get().Distributed + + expect(reconciled.continuityMode).toBe('distributed') + expect(reconciled.members).toEqual([ + expect.objectContaining({ connectionId: 'gateway-a', name: 'research' }), + expect.objectContaining({ connectionId: 'gateway-b', name: 'builder' }) + ]) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('degrades an old gateway without starting the classic Desktop round driver', async () => { + const missing = Object.assign(new Error('method not found'), { + code: -32601 + }) + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities' || method === 'groups.send') { + throw missing + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Legacy: room({ + log: [ + { + at: 1, + from: { + kind: 'user', + name: 'You' + }, + id: 'legacy-1', + text: 'Keep going', + thread: 'thread-1' + } + ] + }) + }) + + const localProjection = loaded.chat.groupChatSyncSnapshot(loaded.chat.$groupChats.get()) + + const mergedProjection = loaded.chat.mergeGroupChatSyncSnapshots( + { + version: 3, + rooms: { + 'id:room-1': { + name: 'Legacy', + roomId: 'room-1', + log: [], + revision: 9, + hosted: 'install:untrusted-projection', + hostedEpoch: 9, + continuityMode: 'gateway' + } + } + }, + localProjection + ) + + expect(mergedProjection.rooms['id:room-1']).toMatchObject({ + hosted: 'install:home', + hostedEpoch: 1, + continuityMode: 'gateway' + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.chat.$groupChats.get().Legacy).toMatchObject({ + hosted: 'install:home', + continuityMode: 'gateway', + running: false, + hostedStatus: { + state: 'unsupported', + label: 'Update this device to keep this Group Chat running.' + } + }) + + const thread = loaded.rounds.sendToGroupChat('Legacy', MEMBERS, 'Continue', null, []) + + expect(thread).toBeTruthy() + await Promise.resolve() + await Promise.resolve() + expect(loaded.calls.some(call => call.method === 'session.create' || call.method === 'prompt.submit')).toBe(false) + expect(loaded.chat.$groupChats.get().Legacy.hosted).toBe('install:home') + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('persists send, stop, and disband commands before dispatch and acknowledges them idempotently', async () => { + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home' + } + } + + if (method === 'groups.list') { + return { + rooms: [] + } + } + + if (method === 'groups.create') { + return { + room: { + room_id: 'room-new', + authority_gateway_id: 'install:home', + authority_epoch: 1 + } + } + } + + if (method === 'groups.send' || method === 'groups.stop' || method === 'groups.disband') { + return { + ok: true + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room() + }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const probe = await loaded.runtime.probeHostedRoomMembers(MEMBERS) + + await expect( + loaded.runtime.createHostedGroupChat({ + route: probe.route, + roomId: 'room-new', + name: 'New Group', + members: MEMBERS.map(member => ({ + member_id: member.name, + profile: member.name, + handle: member.name + })) + }) + ).resolves.toEqual({ + authorityId: 'install:home', + authorityEpoch: 1, + connectionId: 'gateway-a' + }) + + await expect( + loaded.runtime.sendHostedGroupChat( + 'Release', + { + at: 1, + from: { + kind: 'user', + name: 'You' + }, + id: 'send-1', + text: 'Ship it', + thread: 'thread-1' + }, + 'thread-1' + ) + ).resolves.toBe(true) + await expect(loaded.runtime.stopHostedGroupChat('Release')).resolves.toBe(true) + await expect(loaded.runtime.disbandHostedGroupChat('Release')).resolves.toBe(true) + + expect(loaded.calls.map(call => call.method)).toEqual( + expect.arrayContaining(['groups.create', 'groups.send', 'groups.stop', 'groups.disband']) + ) + expect((loaded.storage.get('hosted-room-outbox-v1') as { commands: unknown[] }).commands).toEqual([]) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('confirms an unknown create outcome before allowing Desktop fallback', async () => { + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home' + } + } + + if (method === 'groups.list') { + return { + rooms: [] + } + } + + if (method === 'groups.create') { + throw new Error('response lost') + } + + if (method === 'groups.state') { + return { + room: { + room_id: 'room-new', + name: 'New Group', + authority_gateway_id: 'install:home', + authority_epoch: 1 + } + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + const probe = await loaded.runtime.probeHostedRoomMembers(MEMBERS) + + await expect( + loaded.runtime.createHostedGroupChat({ + route: probe.route, + roomId: 'room-new', + name: 'New Group', + members: MEMBERS.map(member => ({ + member_id: member.name, + profile: member.name, + handle: member.name + })) + }) + ).resolves.toEqual({ + authorityId: 'install:home', + authorityEpoch: 1, + connectionId: 'gateway-a' + }) + expect(loaded.calls.map(call => call.method)).toEqual(expect.arrayContaining(['groups.create', 'groups.state'])) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('retries a hosted rename with the same idempotency key', async () => { + let renameAttempts = 0 + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home' + } + } + + if (method === 'groups.list') { + return { + rooms: [] + } + } + + if (method === 'groups.rename') { + renameAttempts += 1 + + if (renameAttempts === 1) { + throw new Error('connection closed') + } + + return { + room: { + room_id: 'room-1', + name: 'Renamed' + } + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room() + }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const staleRefreshGeneration = loaded.runtime.beginHostedRoomMutation('room-1') + + await expect(loaded.runtime.renameHostedGroupChat('Release', 'Renamed')).resolves.toBe(false) + + expect(loaded.runtime.hostedRoomMutationIsCurrent('room-1', staleRefreshGeneration)).toBe(false) + + const pending = loaded.storage.get('hosted-room-outbox-v1') as { + commands: Array<{ commandId: string; kind: string; status: string }> + } + + expect(pending.commands).toEqual([ + expect.objectContaining({ + kind: 'rename', + status: 'pending' + }) + ]) + + await loaded.runtime.dispatchHostedRoomOutbox() + + const calls = loaded.calls.filter(call => call.method === 'groups.rename') + + expect(calls).toHaveLength(2) + expect(calls[0].params.event_id).toBe(calls[1].params.event_id) + expect(calls[1].params).toMatchObject({ + room_id: 'room-1', + name: 'Renamed' + }) + expect(loaded.storage.get('hosted-room-outbox-v1')).toMatchObject({ + commands: [] + }) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('replays an unknown in-flight send after Desktop closes with the same command id', async () => { + let releaseFirstSend: () => void = () => undefined + let firstSend = true + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home' + } + } + + if (method === 'groups.list') { + return { + rooms: [] + } + } + + if (method === 'groups.send' && firstSend) { + firstSend = false + + return new Promise(resolve => { + releaseFirstSend = () => + resolve({ + ok: true + }) + }) + } + + if (method === 'groups.send') { + return { + ok: true + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room() + }) + const storage = scriptedStorage(loaded.storage).storage + + await loaded.runtime.startHostedRoomRuntime(storage) + + const delivery = loaded.runtime.sendHostedGroupChat( + 'Release', + { + at: 1, + from: { + kind: 'user', + name: 'You' + }, + id: 'send-after-close', + text: 'Keep working', + thread: 'thread-1' + }, + 'thread-1' + ) + + for (let attempt = 0; attempt < 10; attempt++) { + await Promise.resolve() + } + + expect(loaded.storage.get('hosted-room-outbox-v1')).toMatchObject({ + commands: [ + { + commandId: 'send-after-close', + status: 'in-flight' + } + ] + }) + + loaded.runtime.stopHostedRoomRuntime() + releaseFirstSend() + await expect(delivery).resolves.toBe(false) + + await loaded.runtime.startHostedRoomRuntime(storage) + + expect(loaded.calls.filter(call => call.method === 'groups.send')).toHaveLength(2) + expect(loaded.storage.get('hosted-room-outbox-v1')).toMatchObject({ + commands: [] + }) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it.each(['send', 'disband'] as const)( + 'replays a persisted %s after the hosted worker recovers from 4123', + async kind => { + let available = false + let accepted = 0 + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home' + } + } + + if (method === 'groups.list') { + return { rooms: [] } + } + + if (method === `groups.${kind}`) { + if (!available) { + throw Object.assign(new Error('Group Chat worker is unavailable'), { + code: 4123 + }) + } + + accepted += 1 + + return { ok: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ Release: room() }) + const storage = scriptedStorage(loaded.storage).storage + + await loaded.runtime.startHostedRoomRuntime(storage) + + const submitted = + kind === 'send' + ? await loaded.runtime.sendHostedGroupChat( + 'Release', + { + at: 1, + from: { kind: 'user', name: 'You' }, + id: 'worker-restart-send', + text: 'Keep working', + thread: 'thread-1' + }, + 'thread-1' + ) + : await loaded.runtime.disbandHostedGroupChat('Release') + + expect(submitted).toBe(false) + + const persisted = loaded.storage.get('hosted-room-outbox-v1') as { + commands: Array<{ commandId: string; status: string }> + } + + expect(persisted.commands).toHaveLength(1) + expect(persisted.commands[0].status).toBe('pending') + + const commandId = persisted.commands[0].commandId + + loaded.runtime.stopHostedRoomRuntime() + available = true + await loaded.runtime.startHostedRoomRuntime(storage) + + const calls = loaded.calls.filter(call => call.method === `groups.${kind}`) + + expect(calls).toHaveLength(2) + expect( + calls.map(call => String(call.params.event_id || call.params.cancel_id || '')) + ).toEqual([commandId, commandId]) + expect(accepted).toBe(1) + expect(loaded.storage.get('hosted-room-outbox-v1')).toMatchObject({ + commands: [] + }) + + loaded.runtime.stopHostedRoomRuntime() + } + ) + + it('creates a multi-host Group Chat with target-issued scoped grants', async () => { + const routes = [ + { connectionId: 'host-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'host-b', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'host-b', mode: 'remote' as const, profile: 'builder', targetProfile: 'builder' } + ] + + const loaded = await loadRuntime((method, _params, route) => { + const connectionId = String(route?.connectionId || '') + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: `install:${connectionId}`, + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { + available: true, + url: `https://${connectionId}.example.test:19445` + }, + catalog: { + attachments: false, + catalog_digest: `digest:${connectionId}`, + installation_id: `install:${connectionId}`, + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.list') { + return { rooms: [] } + } + + if (method === 'groups.peer.invite') { + return { + grant: 'grant:builder', + target_profile: 'builder', + catalog: { + attachments: false, + catalog_digest: 'digest:host-b', + installation_id: 'install:host-b', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + + if (method === 'groups.create') { + return { + room: { + authority_epoch: 1, + authority_gateway_id: 'install:host-a', + room_id: 'room-multi' + } + } + } + + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }, routes) + + const storage = scriptedStorage(loaded.storage).storage + + await loaded.runtime.startHostedRoomRuntime(storage) + + const members: GroupMember[] = [ + { + connectionId: 'host-a', + name: 'research', + route: routes[0], + sourceScoped: true, + targetProfile: 'research' + }, + { + connectionId: 'host-b', + name: 'builder', + route: routes[2], + sourceScoped: true, + targetProfile: 'builder' + } + ] + + const probe = await loaded.runtime.probeHostedRoomMembers(members) + + expect(probe.route).toMatchObject({ + homeConnectionId: 'host-a', + kind: 'multi-gateway', + remoteConnectionIds: ['host-b'] + }) + await expect( + loaded.runtime.createAutonomousHostedGroupChat({ + members: [ + { handle: 'research', member: members[0], profile: 'research' }, + { handle: 'builder', member: members[1], profile: 'builder' } + ], + name: 'Multi', + probe, + roomId: 'room-multi' + }) + ).resolves.toMatchObject({ + authorityId: 'install:host-a', + connectionId: 'host-a', + continuityMode: 'distributed' + }) + + expect(loaded.calls.find(call => call.method === 'groups.peer.invite')?.connectionId).toBe('host-b') + expect(loaded.calls.find(call => call.method === 'groups.create')?.connectionId).toBe('host-a') + expect(loaded.calls.find(call => call.method === 'groups.peer.register')?.params).toMatchObject({ + grant: 'grant:builder', + member_id: 'member-2-builder', + room_id: 'room-multi', + target_profile: 'builder', + target_url: 'https://host-b.example.test:19445/p/builder' + }) + expect((loaded.storage.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('durably disbands and revokes a partial multi-host setup', async () => { + const routes = [ + { connectionId: 'host-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'host-b', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'host-b', mode: 'remote' as const, profile: 'builder', targetProfile: 'builder' } + ] + + let cleanupAvailable = false + + const loaded = await loadRuntime((method, _params, route) => { + const connectionId = String(route?.connectionId || '') + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: `install:${connectionId}`, + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: `https://${connectionId}.example.test:19445` }, + catalog: { + attachments: false, + catalog_digest: `digest:${connectionId}`, + installation_id: `install:${connectionId}`, + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.list') { + return { rooms: [] } + } + + if (method === 'groups.peer.invite') { + return { + grant: 'grant:builder', + target_profile: 'builder', + catalog: { + attachments: false, + catalog_digest: 'digest:host-b', + installation_id: 'install:host-b', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + + if (method === 'groups.create' || method === 'groups.state') { + throw new Error('create failed') + } + + if (method === 'groups.disband' || method === 'groups.peer.revoke') { + if (!cleanupAvailable) { + throw new Error('device offline') + } + + return { ok: true } + } + + throw new Error(`unexpected method: ${method}`) + }, routes) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const members: GroupMember[] = [ + { connectionId: 'host-a', name: 'research', route: routes[0], sourceScoped: true, targetProfile: 'research' }, + { connectionId: 'host-b', name: 'builder', route: routes[2], sourceScoped: true, targetProfile: 'builder' } + ] + + const probe = await loaded.runtime.probeHostedRoomMembers(members) + + const failure = await loaded.runtime + .createAutonomousHostedGroupChat({ + members: [ + { handle: 'research', member: members[0], profile: 'research' }, + { handle: 'builder', member: members[1], profile: 'builder' } + ], + name: 'Partial', + probe, + roomId: 'room-partial' + }) + .catch(error => error as Error & { fallbackSafe?: boolean }) + + expect(failure).toMatchObject({ + fallbackSafe: false, + message: expect.stringContaining('could not finish cleanup') + }) + expect((loaded.storage.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).not.toEqual([]) + + cleanupAvailable = true + loaded.runtime.stopHostedRoomRuntime() + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.calls.map(call => call.method)).toEqual( + expect.arrayContaining(['groups.disband', 'groups.peer.revoke']) + ) + expect((loaded.storage.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('reprobes deterministic unsupported gateways only after the 30-second cache expires', async () => { + vi.setSystemTime(100) + let probes = 0 + const missing = Object.assign(new Error('method not found'), { code: -32601 }) + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + probes += 1 + throw missing + } + + throw new Error(`unexpected method: ${method}`) + }) + + await loaded.runtime.probeHostedRoomMembers(MEMBERS) + await loaded.runtime.probeHostedRoomMembers(MEMBERS) + expect(probes).toBe(1) + + vi.setSystemTime(30_101) + await loaded.runtime.probeHostedRoomMembers(MEMBERS) + expect(probes).toBe(2) + }) + + it('keeps a projection-only room read-only until its member gateway inventory settles', async () => { + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:home', + driver: true, + persistent_process: true + } + } + + if (method === 'groups.list') { + return { rooms: [] } + } + + throw new Error(`unexpected method: ${method}`) + }) + + const projected = room({ + hosted: null, + hostedConnectionId: null, + hostedEpoch: null, + continuityMode: 'desktop', + members: MEMBERS.map(member => ({ ...member, remoteSource: true })) + }) + + loaded.chat.$groupChats.set({ Projected: projected }) + expect(loaded.runtime.groupChatContinuityReady(projected)).toBe(false) + expect(loaded.rounds.sendToGroupChat('Projected', projected.members || [], 'Do not double-drive')).toBeNull() + expect(loaded.chat.$groupChats.get().Projected.continuityIssue).toBe('Syncing recent activity…') + expect(loaded.calls.some(call => call.method === 'session.create' || call.method === 'prompt.submit')).toBe(false) + expect( + loaded.runtime.groupChatContinuityReady({ + ...projected, + members: MEMBERS + }) + ).toBe(true) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.runtime.groupChatContinuityReady(projected)).toBe(true) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('keeps a remotely deleted room read-only without painting a local send', async () => { + const loaded = await loadRuntime(method => { + throw new Error(`deleted room must not dispatch: ${method}`) + }) + const deleted = room({ + hostedStatus: { label: 'Deleted', state: 'deleted' }, + running: false + }) + + loaded.chat.$groupChats.set({ Deleted: deleted }) + expect(loaded.runtime.groupChatContinuityReady(deleted)).toBe(false) + expect(loaded.rounds.sendToGroupChat('Deleted', MEMBERS, 'must not paint')).toBeNull() + expect(loaded.chat.$groupChats.get().Deleted.log).toEqual([]) + expect(loaded.calls.some(call => call.method === 'groups.send')).toBe(false) + }) + + it('does not enqueue another Stop while a hosted Stop is already pending', async () => { + const loaded = await loadRuntime(method => { + throw new Error(`stopping room must not dispatch: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Stopping: room({ + hostedStatus: { canStop: false, label: 'Stopping…', state: 'stopping' }, + running: true + }) + }) + await loaded.rounds.stopGroupThread('Stopping', null, MEMBERS) + + expect(loaded.calls.some(call => call.method === 'groups.stop')).toBe(false) + expect(loaded.chat.$groupChats.get().Stopping.hostedStatus?.state).toBe('stopping') + }) + + it.each(['send', 'stop'] as const)('does not let a stale replay overwrite a newer hosted %s', async action => { + let exposeRoom = false + let releaseLog: () => void = () => undefined + let logStarted: () => void = () => undefined + + const logRequested = new Promise(resolve => { + logStarted = resolve + }) + + const heldLog = new Promise>(resolve => { + releaseLog = () => resolve({ events: [], has_more: false, latest_seq: 0 }) + }) + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:home', + driver: true, + persistent_process: true + } + } + + if (method === 'groups.list') { + return { + rooms: exposeRoom + ? [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: 0, + members: MEMBERS, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + : [] + } + } + + if (method === 'groups.state') { + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + logStarted() + + return heldLog + } + + if (method === 'groups.send' || method === 'groups.stop') { + return { ok: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room({ + hostedStatus: { label: 'Working', state: 'working' }, + running: true + }) + }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + exposeRoom = true + + const refresh = loaded.runtime.refreshHostedRooms() + await logRequested + + if (action === 'send') { + expect(loaded.rounds.sendToGroupChat('Release', MEMBERS, 'Keep going')).toBeTruthy() + } else { + await loaded.rounds.stopGroupThread('Release', null, MEMBERS) + } + + for (let attempt = 0; attempt < 10; attempt += 1) { + await Promise.resolve() + } + + const expectedState = loaded.chat.$groupChats.get().Release.hostedStatus?.state + + if (action === 'send') { + expect(['sending', 'working']).toContain(expectedState) + } else { + expect(expectedState).toBe('stopped') + } + + releaseLog() + await refresh + + expect(loaded.chat.$groupChats.get().Release.hostedStatus?.state).toBe(expectedState) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('continues a bounded partial replay on the next refresh and offers Retry while incomplete', async () => { + const events = Array.from({ length: 21 }, (_, index) => + hostedEvent(index + 1, `message-${index + 1}`, 'message.user', { + text: `Message ${index + 1}`, + thread_id: 'thread-1' + }) + ) + + const loaded = await loadRuntime((method, params) => { + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:home', + driver: true, + max_log_limit: 1, + persistent_process: true + } + } + + if (method === 'groups.list') { + return { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: events.length, + members: MEMBERS, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + const since = Number(params.since_seq || 0) + + return { + events: events.slice(since, since + 1), + has_more: since + 1 < events.length, + latest_seq: events.length + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ Release: room() }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.chat.$groupChats.get().Release).toMatchObject({ + hostedSeq: 20, + hostedStatus: { canRetry: true }, + continuityIssue: 'Syncing recent activity…' + }) + + await loaded.runtime.refreshHostedRooms() + + expect(loaded.chat.$groupChats.get().Release).toMatchObject({ + hostedSeq: 21, + continuityIssue: null + }) + expect(loaded.calls.filter(call => call.method === 'groups.log')).toHaveLength(21) + expect(loaded.calls.filter(call => call.method === 'groups.log').every(call => call.params.limit === 1)).toBe(true) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('does not resurrect an idle room deleted while replay is in flight', async () => { + let releaseLog: () => void = () => undefined + let logStarted: () => void = () => undefined + + const logRequested = new Promise(resolve => { + logStarted = resolve + }) + + const heldLog = new Promise>(resolve => { + releaseLog = () => resolve({ events: [], has_more: false, latest_seq: 0 }) + }) + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { authority_gateway_id: 'install:home', driver: true, persistent_process: true } + } + + if (method === 'groups.list') { + return { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + logStarted() + + return heldLog + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ Release: room({ running: false }) }) + const refresh = loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + await logRequested + + loaded.runtime.markHostedRoomLocallyDeleted('room-1') + loaded.chat.$groupChats.set({}) + releaseLog() + await refresh + + expect(loaded.chat.$groupChats.get().Release).toBeUndefined() + loaded.runtime.stopHostedRoomRuntime() + }) + + it('separates queued work from active work and fingerprints idle room state', async () => { + const loaded = await loadRuntime(() => ({})) + + expect( + loaded.runtime.hostedRoomDriverDisplayStatus({ kind: 'ready' }, { counts: { queued: 1 }, working: false }) + ).toMatchObject({ kind: 'queued', canStop: true }) + expect(loaded.runtime.hostedRoomPollFingerprint({ revision: 4, latest_seq: 9 })).toBe('4:9') + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts new file mode 100644 index 0000000000000..13c70e7437959 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts @@ -0,0 +1,1708 @@ +/** + * Gateway-hosted Group Chat runtime. + * + * RPC ownership lives here: capability negotiation, the durable command + * outbox, monotonic replay, and the bounded refresh loop. Group state remains + * owned by `group-chat.ts`; creation, round routing, and room UI call this + * module through narrow verbs. + */ + +import { atom, host } from '@hermes/plugin-sdk' +import type { PluginContext } from '@hermes/plugin-sdk' + +import { $lastRoster } from './data' +import { + $groupChats, + applyHostedRoomAuthority, + groupChatHostedGateway, + mergeGroupChatSyncEntries, + uniqueGroupChatName, + updateGroupChat +} from './group-chat' +import { + classifyHostedRoomCapability, + createHostedRoomOutbox, + createHostedRoomReplayState, + deriveFriendlyHostedRoomStatus, + isHostedRoomContinuityEligible, + profileScopedRoomLinkEndpoint, + reduceHostedRoomOutbox, + replayHostedRoomPages, + resolveAutonomousRoomPlan +} from './hosted-room-client' +import type { + AutonomousRoomPlan, + FriendlyHostedRoomStatus, + HostedRoomCapability, + HostedRoomCommand, + HostedRoomOutbox, + HostedRoomRouteResolution +} from './hosted-room-client' +import { botsText } from './i18n' +import { requestForBot } from './routing' +import type { GroupChat, GroupMember, GroupMessage, ProfileRoute } from './types' + +export { describeAutonomousRoomPlan, describeHostedRoomCreationError } from './hosted-room-client' + +const HOSTED_ROOM_OUTBOX_KEY = 'hosted-room-outbox-v1' +const HOSTED_ROOM_CLEANUP_KEY = 'hosted-room-cleanup-v1' +const HOSTED_ROOM_CLEANUP_LIMIT = 64 +const HOSTED_ROOM_LIST_PAGE_SIZE = 500 +const HOSTED_ROOM_LIST_MAX_PAGES = 4 +const HOSTED_ROOM_SYNC_INTERVAL_MS = 5000 +const HOSTED_ROOM_UNSUPPORTED_REPROBE_MS = 30_000 + +export const $hostedRoomCapabilities = atom>({}) +export const $hostedRoomOutbox = atom(createHostedRoomOutbox()) +export const $hostedRoomCleanup = atom({ version: 1, operations: [] }) + +const hostedAuthorityRoutes = new Map() +const hostedRoomPollCache = new Map() +const hostedRoomMutationGenerations = new Map() +const hostedRoomLocallyDeleted = new Set() +const hostedRoomInventoriedConnections = new Set() +let hostedRoomSyncTimer: ReturnType | null = null +let hostedRoomSyncRunning = false +let hostedRoomSyncDisposed = true +let hostedOutboxDispatching = false +let hostedCleanupDispatching = false +let hostedRoomStorage: null | PluginContext['storage'] = null +let hostedRoomHooks: HostedRoomRuntimeHooks = {} +const hostedUnsupportedUntil = new Map() + +function hostedRoomMutationGeneration(roomId: string) { + return Math.max(0, Number(hostedRoomMutationGenerations.get(String(roomId || '')) || 0)) +} + +/** Fence an asynchronous local send/Stop/delete against an older replay. */ +export function beginHostedRoomMutation(roomId: string) { + const id = String(roomId || '') + const generation = hostedRoomMutationGeneration(id) + 1 + + if (id) { + hostedRoomMutationGenerations.set(id, generation) + } + + return generation +} + +export function hostedRoomMutationIsCurrent(roomId: string, generation: number) { + const id = String(roomId || '') + + return Boolean(id) && !hostedRoomLocallyDeleted.has(id) && hostedRoomMutationGeneration(id) === generation +} + +/** Keep an acknowledged local deletion invisible to stale in-flight polls. */ +export function markHostedRoomLocallyDeleted(roomId: string) { + const id = String(roomId || '') + + if (!id) { + return + } + + beginHostedRoomMutation(id) + hostedRoomLocallyDeleted.add(id) + hostedRoomPollCache.delete(id) +} + +/** A projection-only room must not start a classic Desktop driver until each + * member gateway has been inventoried. Existing local classic rooms carry + * either a Desktop authority or non-projected member descriptors and remain + * immediately usable. */ +export function groupChatContinuityReady(room: GroupChat | null | undefined) { + if (!room) { + return true + } + + if (groupChatHostedGateway(room)) { + return room.hostedStatus?.state !== 'deleted' + } + + if (!room.roomId) { + return true + } + + const members = Array.isArray(room.members) ? room.members : [] + + if (!members.length || members.some(member => member.remoteSource !== true)) { + return true + } + + const connections = [...new Set(members.map(member => String(member.connectionId || '')).filter(Boolean))] + + return !connections.length || connections.every(connectionId => hostedRoomInventoriedConnections.has(connectionId)) +} + +export interface HostedRoomRuntimeHooks { + renameGroupChat?: (oldName: string, newName: string, members: GroupMember[]) => Promise +} + +export interface HostedRoomProbe { + capability: HostedRoomCapability | null + capabilities: Record + eligible: boolean + route: AutonomousRoomPlan + routes: Record +} + +interface HostedRoomCleanupOperation { + cancelId?: null | string + connectionId: string + grant?: null | string + kind: 'home-disband' | 'peer-revoke' + operationId: string + ownerId: string + profile?: null | string + roomId?: null | string + setupId: string +} + +interface HostedRoomCleanup { + operations: HostedRoomCleanupOperation[] + version: 1 +} + +interface HostedRoomCreateInput { + members: Array<{ + display_name?: string + handle: string + member_id: string + profile: string + }> + name: string + roomId: string + route: HostedRoomRouteResolution +} + +interface AutonomousHostedRoomMember { + displayName?: string + handle: string + member: GroupMember + profile: string +} + +interface AutonomousHostedRoomCreateInput { + members: AutonomousHostedRoomMember[] + name: string + probe: HostedRoomProbe + roomId: string +} + +interface HostedRoomServerMember { + display_name?: unknown + handle?: unknown + member_id?: unknown + profile?: unknown + target?: unknown +} + +interface HostedRoomServerState { + authority_epoch?: unknown + authority_gateway_id?: unknown + disbanded_at?: unknown + latest_seq?: unknown + members?: unknown + name?: unknown + room_id?: unknown +} + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null +} + +function activeConnectionId() { + return String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || '') +} + +async function hostedDefaultRoutes(): Promise { + if (typeof host.profileRoutes !== 'function') { + return [] + } + + const routes = await host.profileRoutes() + const byConnection = new Map() + + for (const route of Array.isArray(routes) ? routes : []) { + const profile = String(route?.targetProfile || route?.profile || '') + const connectionId = String(route?.connectionId || '') + + if (!connectionId || profile !== 'default' || byConnection.has(connectionId)) { + continue + } + + byConnection.set(connectionId, route as ProfileRoute) + } + + return [...byConnection.values()] +} + +async function requestHostedConnection( + route: ProfileRoute, + method: string, + params: Record = {} +): Promise { + if (!route?.connectionId || typeof host.requestProfile !== 'function') { + throw new Error(botsText().group.hostRouteMissing) + } + + return host.requestProfile(route, method, params) as Promise +} + +const hostedCleanupOwnerId = + globalThis.crypto?.randomUUID?.() || `desktop-${Date.now()}-${Math.random().toString(36).slice(2)}` + +function normalizeHostedRoomCleanup(value: unknown): HostedRoomCleanup { + const candidate = record(value) + const operations: HostedRoomCleanupOperation[] = [] + + for (const raw of Array.isArray(candidate?.operations) ? candidate.operations : []) { + const operation = record(raw) + const operationId = String(operation?.operationId || '') + const setupId = String(operation?.setupId || '') + const kind = String(operation?.kind || '') + const connectionId = String(operation?.connectionId || '') + + if (!operationId || !setupId || !connectionId || !['home-disband', 'peer-revoke'].includes(kind)) { + continue + } + + if (kind === 'home-disband' && !String(operation?.roomId || '')) { + continue + } + + if (kind === 'peer-revoke' && (!String(operation?.grant || '') || !String(operation?.profile || ''))) { + continue + } + + operations.push({ + operationId, + setupId, + kind: kind as HostedRoomCleanupOperation['kind'], + connectionId, + ownerId: String(operation?.ownerId || ''), + roomId: kind === 'home-disband' ? String(operation?.roomId || '') : null, + cancelId: + kind === 'home-disband' ? String(operation?.cancelId || `rollback-${String(operation?.roomId || '')}`) : null, + profile: kind === 'peer-revoke' ? String(operation?.profile || '') : null, + grant: kind === 'peer-revoke' ? String(operation?.grant || '') : null + }) + } + + return { + version: 1, + operations: operations.slice(-HOSTED_ROOM_CLEANUP_LIMIT) + } +} + +async function replaceHostedRoomCleanup(next: HostedRoomCleanup) { + if (!hostedRoomStorage?.set) { + throw new Error('Desktop storage is unavailable, so Group Chat setup cannot be secured.') + } + + const previous = $hostedRoomCleanup.get() + + $hostedRoomCleanup.set(next) + + try { + await hostedRoomStorage.set(HOSTED_ROOM_CLEANUP_KEY, next) + } catch (error) { + $hostedRoomCleanup.set(previous) + throw error + } +} + +async function addHostedRoomCleanup(operation: Omit) { + const current = normalizeHostedRoomCleanup($hostedRoomCleanup.get()) + + const next = normalizeHostedRoomCleanup({ + version: 1, + operations: [ + ...current.operations.filter(entry => entry.operationId !== operation.operationId), + { + ...operation, + ownerId: hostedCleanupOwnerId + } + ] + }) + + if (next.operations.length >= HOSTED_ROOM_CLEANUP_LIMIT && current.operations.length >= HOSTED_ROOM_CLEANUP_LIMIT) { + throw new Error('Group Chat cleanup is pending. Reconnect the affected devices before creating another.') + } + + await replaceHostedRoomCleanup(next) +} + +async function releaseHostedRoomCleanup(setupId: string) { + const current = normalizeHostedRoomCleanup($hostedRoomCleanup.get()) + + await replaceHostedRoomCleanup({ + version: 1, + operations: current.operations.filter(operation => operation.setupId !== setupId) + }) +} + +async function armHostedRoomCleanup(setupId: string) { + const current = normalizeHostedRoomCleanup($hostedRoomCleanup.get()) + + await replaceHostedRoomCleanup({ + version: 1, + operations: current.operations.map(operation => + operation.setupId === setupId + ? { + ...operation, + ownerId: '' + } + : operation + ) + }) +} + +async function hostedRouteForReference(connectionId: string, profile = 'default') { + if (typeof host.profileRoutes !== 'function') { + return null + } + + const routes = await host.profileRoutes() + + return ((Array.isArray(routes) ? routes : []).find(route => { + const routeProfile = String(route?.targetProfile || route?.profile || '') + + return String(route?.connectionId || '') === connectionId && routeProfile === profile + }) || null) as ProfileRoute | null +} + +function hostedCleanupAlreadySettled(operation: HostedRoomCleanupOperation, error: unknown) { + const candidate = record(error) + const inner = record(candidate?.error) + const code = Number(candidate?.code ?? inner?.code) + const message = String(candidate?.message || inner?.message || error || '') + + return operation.kind === 'home-disband' && code === 4113 && /hosted room not found|already disbanded/i.test(message) +} + +async function dispatchHostedRoomCleanup() { + if (hostedCleanupDispatching || hostedRoomSyncDisposed) { + return + } + + hostedCleanupDispatching = true + + try { + for (const operation of normalizeHostedRoomCleanup($hostedRoomCleanup.get()).operations) { + if (operation.ownerId === hostedCleanupOwnerId) { + continue + } + + const profile = operation.kind === 'peer-revoke' ? String(operation.profile || '') : 'default' + const route = await hostedRouteForReference(operation.connectionId, profile) + + if (!route) { + continue + } + + try { + if (operation.kind === 'home-disband') { + await requestHostedConnection(route, 'groups.disband', { + room_id: operation.roomId, + cancel_id: operation.cancelId + }) + } else { + await requestHostedConnection(route, 'groups.peer.revoke', { + grant: operation.grant, + profile: operation.profile + }) + } + } catch (error) { + if (!hostedCleanupAlreadySettled(operation, error)) { + continue + } + } + + const latest = normalizeHostedRoomCleanup($hostedRoomCleanup.get()) + + await replaceHostedRoomCleanup({ + version: 1, + operations: latest.operations.filter(entry => entry.operationId !== operation.operationId) + }) + } + } finally { + hostedCleanupDispatching = false + } +} + +async function withHostedRoomProbeTimeout(task: Promise, timeoutMs = 3000) { + let timer: null | ReturnType = null + + try { + return await Promise.race([ + task, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Host check timed out')), timeoutMs) + }) + ]) + } finally { + if (timer !== null) { + clearTimeout(timer) + } + } +} + +function sourceLabel(connectionId: string) { + const source = ($lastRoster.get() || []).find(row => String(row?.connectionId || '') === connectionId) + + return String(source?.connectionLabel || botsText().group.thisHost) +} + +function hostedMemberDescriptors( + room: HostedRoomServerState, + homeConnectionId: string, + existingMembers: GroupMember[], + capabilities: Record +): GroupMember[] { + return (Array.isArray(room?.members) ? room.members : []).map(raw => { + const member = (record(raw) || {}) as HostedRoomServerMember + const profile = String(member.profile || member.member_id || 'default') + const handle = String(member.handle || member.profile || 'hermes') + const target = record(member.target) + + const targetAuthority = + target?.kind === 'peer' ? String(target.installation_id || target.peer_id || '') : '' + + const prior = (existingMembers || []).find( + candidate => + String(candidate?.handle || candidate?.name || '') === handle && + String(candidate?.targetProfile || candidate?.name || '') === profile + ) + + const peerConnectionId = targetAuthority + ? Object.entries(capabilities).find(([, capability]) => capability.authorityId === targetAuthority)?.[0] || '' + : '' + + const connectionId = targetAuthority ? peerConnectionId || String(prior?.connectionId || '') : homeConnectionId + + const connectionLabel = connectionId + ? sourceLabel(connectionId) + : String(prior?.connectionLabel || '') + + const sourceReachable = connectionId + ? capabilities[connectionId] + ? isHostedRoomContinuityEligible(capabilities[connectionId]) + : prior?.sourceReachable !== false + : false + + return { + name: profile, + handle, + title: String(member.display_name || ''), + ...(connectionId + ? { + connectionId, + connectionLabel, + route: { + connectionId, + mode: 'remote', + profile, + targetProfile: profile + } + } + : { + sourceMissing: true, + sourceReachable: false + }), + remoteSource: true, + sourceScoped: true, + sourceReachable, + targetProfile: profile + } + }) +} + +function hostedRoomContinuityMode(room: HostedRoomServerState) { + return (Array.isArray(room?.members) ? room.members : []).some(raw => record(record(raw)?.target)?.kind === 'peer') + ? ('distributed' as const) + : ('gateway' as const) +} + +function markHostedConnectionUnavailable(connectionId: string, unsupported = false) { + const connectionName = sourceLabel(connectionId) + + for (const [name, room] of Object.entries($groupChats.get())) { + if (String(room?.hostedConnectionId || '') !== connectionId) { + continue + } + + updateGroupChat( + name, + current => ({ + ...current, + running: false, + hostedStatus: { + state: unsupported ? 'unsupported' : 'offline', + label: unsupported + ? botsText().group.hostUpdateNeeded(connectionName) + : botsText().group.hostedUnavailable(connectionName) + }, + continuityIssue: unsupported ? null : botsText().group.hostReconnectToContinue(connectionName) + }), + { + sync: false + } + ) + } +} + +export function hostedRoomDriverDisplayStatus( + replay: FriendlyHostedRoomStatus, + driverValue: unknown, + { stopping = false }: { stopping?: boolean } = {} +): FriendlyHostedRoomStatus { + if (stopping) { + return { ...replay, kind: 'stopping', canStop: false } + } + + if (['failed', 'member-unavailable', 'needs-attention', 'needs-you', 'waiting'].includes(replay.kind)) { + return replay + } + + const driver = record(driverValue) + const counts = record(driver?.counts) + + if (Number(counts?.queued || driver?.queued || 0) > 0) { + return { ...replay, kind: 'queued', canStop: true } + } + + if (driver?.working === true || replay.kind === 'working') { + return { ...replay, kind: 'working', canStop: true } + } + + return replay +} + +function hostedStatus(status: FriendlyHostedRoomStatus, connectionName: string) { + const b = botsText() + const member = status.member || b.group.aBot + + const labels: Record = { + deleted: b.group.hostedDeleted, + offline: b.group.hostedUnavailable(connectionName), + queued: b.group.hostedQueued(connectionName), + ready: b.roster.ready, + stopping: b.group.hostedStopping, + working: b.group.memberThinking(member), + 'member-unavailable': b.group.memberUnavailable(member), + 'needs-attention': b.group.memberNeedsAttention(member), + failed: b.group.memberCouldNotRespond(member), + waiting: b.group.memberRetryWhenOnline(member), + stopped: b.group.hostedStopped, + 'needs-you': b.group.waitingForAnswer + } + + return { + state: status.kind, + label: labels[status.kind] || b.roster.statusUnknown, + ...(status.canRetry === undefined + ? {} + : { + canRetry: status.canRetry + }), + ...(status.canStop === undefined + ? {} + : { + canStop: status.canStop + }) + } +} + +function replayMessages(messages: ReturnType['messages']): GroupMessage[] { + return messages.map(message => ({ + at: message.at, + from: message.from, + id: message.eventId, + eventId: message.eventId, + seq: message.seq, + text: message.text, + thread: message.thread + })) +} + +function isDisbanded(room: HostedRoomServerState) { + return room.disbanded_at !== null && room.disbanded_at !== undefined +} + +export function hostedRoomPollFingerprint(value: unknown) { + const room = record(value) + const revision = Math.max(0, Number(room?.revision || 0)) + const latestSeq = Math.max(0, Number(room?.latest_seq || 0)) + + return `${revision}:${latestSeq}` +} + +export function shouldRefreshHostedRoom(room: GroupChat | undefined, listed: unknown) { + if (!room) { + return true + } + + const activeStates = new Set(['queued', 'sending', 'stopping', 'working']) + + const active = + room.running === true || + activeStates.has(String(room.hostedStatus?.state || '')) || + $hostedRoomOutbox.get().commands.some(command => command.roomId === room.roomId && command.status !== 'failed') + + const fingerprint = hostedRoomPollFingerprint(listed) + + return active || hostedRoomPollCache.get(String(room.roomId || '')) !== fingerprint +} + +/** Replay every hosted room only after plugin storage/ui_meta hydration has + * settled. The contiguous cursor is persisted with the room, so reconnects + * fetch only missing events and a gap never skips unseen history. */ +export async function refreshHostedRooms() { + if (hostedRoomSyncDisposed || hostedRoomSyncRunning) { + return + } + + hostedRoomSyncRunning = true + + try { + const routes = await hostedDefaultRoutes() + + const capabilities = { + ...$hostedRoomCapabilities.get() + } + + for (const route of routes) { + if (hostedRoomSyncDisposed) { + return + } + + const connectionId = String(route.connectionId) + let capability: HostedRoomCapability + + const cached = capabilities[connectionId] + + if (cached?.kind === 'unsupported' && Number(hostedUnsupportedUntil.get(connectionId) || 0) > Date.now()) { + capability = cached + } else { + try { + capability = classifyHostedRoomCapability(await requestHostedConnection(route, 'groups.capabilities'), { + connectionId + }) + } catch (error) { + capability = classifyHostedRoomCapability( + { + ok: false, + error + }, + { + connectionId + } + ) + } + + if (capability.kind === 'unsupported') { + hostedUnsupportedUntil.set(connectionId, Date.now() + HOSTED_ROOM_UNSUPPORTED_REPROBE_MS) + } else { + hostedUnsupportedUntil.delete(connectionId) + } + } + + if (hostedRoomSyncDisposed) { + return + } + + capabilities[connectionId] = capability + + if (!isHostedRoomContinuityEligible(capability) || !capability.authorityId) { + if (capability.kind === 'unsupported') { + hostedRoomInventoriedConnections.add(connectionId) + } + + markHostedConnectionUnavailable(connectionId, capability.kind === 'unsupported') + + continue + } + + hostedAuthorityRoutes.set(capability.authorityId, route) + const listedRooms: unknown[] = [] + let listOffset = 0 + let listComplete = false + + try { + for (let page = 0; page < HOSTED_ROOM_LIST_MAX_PAGES; page += 1) { + const listed = await requestHostedConnection>(route, 'groups.list', { + include_disbanded: true, + limit: HOSTED_ROOM_LIST_PAGE_SIZE, + offset: listOffset + }) + + const rows = Array.isArray(listed?.rooms) ? listed.rooms : [] + + listedRooms.push(...rows) + + const nextOffset = Number(listed?.next_offset) + + if (!Number.isSafeInteger(nextOffset) || nextOffset <= listOffset) { + listComplete = true + + break + } + + listOffset = nextOffset + } + } catch { + markHostedConnectionUnavailable(connectionId) + + continue + } + + if (hostedRoomSyncDisposed) { + return + } + + const disbandedIds = new Set( + listedRooms + .map(raw => (record(raw) || {}) as HostedRoomServerState) + .filter(isDisbanded) + .map(room => String(room.room_id || '')) + .filter(Boolean) + ) + const caughtUpDisbandedIds = new Set() + + for (const listedRaw of listedRooms) { + const listedRoom = (record(listedRaw) || {}) as HostedRoomServerState + const roomId = String(listedRoom.room_id || '') + const serverName = String(listedRoom.name || '').trim() + + if (!roomId || !serverName || hostedRoomLocallyDeleted.has(roomId)) { + continue + } + + const existingEntry = Object.entries($groupChats.get()).find( + ([, room]) => String(room?.roomId || '') === roomId + ) + const includeDisbanded = isDisbanded(listedRoom) + + // A client that already joined the room must replay terminal events + // committed while it was offline before painting the remote disband. + // Unknown disbanded rooms remain invisible on newly connected clients. + if (includeDisbanded && !existingEntry) { + continue + } + + if (!shouldRefreshHostedRoom(existingEntry?.[1], listedRoom)) { + if ( + includeDisbanded && + Math.max(0, Number(existingEntry?.[1]?.hostedSeq || 0)) >= + Math.max(0, Number(listedRoom.latest_seq || 0)) + ) { + caughtUpDisbandedIds.add(roomId) + } + + continue + } + + const refreshGeneration = hostedRoomMutationGeneration(roomId) + + let stateResponse: Record + + try { + stateResponse = await requestHostedConnection(route, 'groups.state', { + room_id: roomId, + ...(includeDisbanded ? { include_disbanded: true } : {}) + }) + } catch { + markHostedConnectionUnavailable(connectionId) + + continue + } + + if (hostedRoomSyncDisposed) { + return + } + + if (!hostedRoomMutationIsCurrent(roomId, refreshGeneration)) { + continue + } + + const serverRoom = (record(stateResponse.room) || listedRoom) as unknown as HostedRoomServerState + + let existingName = existingEntry?.[0] + let existing = existingEntry?.[1] + const taken = new Set(Object.keys($groupChats.get())) + + let localName = + existingName || + (taken.has(serverName) + ? uniqueGroupChatName(`${serverName} (${sourceLabel(connectionId)})`, taken) + : serverName) + + const renamePending = $hostedRoomOutbox + .get() + .commands.some( + command => command.kind === 'rename' && command.roomId === roomId && command.status !== 'failed' + ) + + if (existingName && existingName !== serverName && !renamePending && hostedRoomHooks.renameGroupChat) { + const occupant = $groupChats.get()[serverName] + const renameTaken = new Set(taken) + + renameTaken.delete(existingName) + + const targetName = + occupant && occupant.roomId !== roomId + ? uniqueGroupChatName(`${serverName} (${sourceLabel(connectionId)})`, renameTaken) + : serverName + + const renamed = await hostedRoomHooks.renameGroupChat( + existingName, + targetName, + Array.isArray(existing?.members) ? existing.members : [] + ) + + if (renamed) { + existingName = renamed + localName = renamed + existing = $groupChats.get()[renamed] + } + + if (hostedRoomSyncDisposed) { + return + } + + if (!hostedRoomMutationIsCurrent(roomId, refreshGeneration)) { + continue + } + } + + const replay = await replayHostedRoomPages({ + state: createHostedRoomReplayState({ + roomId, + name: serverName, + members: Array.isArray(serverRoom.members) ? (serverRoom.members as Array>) : [], + authorityId: String(serverRoom.authority_gateway_id || capability.authorityId), + authorityEpoch: Number(serverRoom.authority_epoch || 1), + connectionId, + cursor: Number(existing?.hostedSeq || 0) + }), + fetchPage: request => + requestHostedConnection(route, 'groups.log', { + room_id: roomId, + since_seq: request.sinceSeq, + limit: request.limit, + ...(includeDisbanded ? { include_disbanded: true } : {}) + }), + pageSize: capability.maxLogLimit || 100 + }) + + if (hostedRoomSyncDisposed) { + return + } + + if (!hostedRoomMutationIsCurrent(roomId, refreshGeneration)) { + continue + } + + const replayStatus = deriveFriendlyHostedRoomStatus(replay.state) + const driver = record(stateResponse.driver_status) + + const stopping = $hostedRoomOutbox + .get() + .commands.some( + command => + command.roomId === roomId && ['disband', 'stop'].includes(command.kind) && command.status !== 'failed' + ) + + const friendly = hostedRoomDriverDisplayStatus(replayStatus, driver, { stopping }) + const running = ['queued', 'stopping', 'working'].includes(friendly.kind) + + const retryAction = (Array.isArray(driver?.pending_actions) ? driver.pending_actions : []) + .map(record) + .find(action => action?.kind === 'retry' && String(action?.task_id || '')) + + updateGroupChat( + localName, + current => { + const authoritative = applyHostedRoomAuthority(current, serverRoom as Record) + + return { + ...authoritative, + roomId, + members: hostedMemberDescriptors(serverRoom, connectionId, current.members || [], capabilities), + log: mergeGroupChatSyncEntries(current.log || [], replayMessages(replay.state.messages)), + hostedConnectionId: connectionId, + hostedSeq: replay.state.cursor, + hostedStatus: { + ...hostedStatus(friendly, sourceLabel(connectionId)), + ...(retryAction ? { taskId: String(retryAction.task_id) } : {}), + ...(!replay.complete ? { canRetry: true } : {}) + }, + continuityMode: hostedRoomContinuityMode(serverRoom), + continuityIssue: replay.complete ? null : botsText().group.hostedSyncing, + running + } + }, + { + sync: false + } + ) + + if (replay.complete) { + hostedRoomPollCache.set(roomId, hostedRoomPollFingerprint(listedRoom)) + + if (includeDisbanded) { + caughtUpDisbandedIds.add(roomId) + } + } else { + hostedRoomPollCache.delete(roomId) + } + } + + // Keep the local shell long enough to explain a disband observed on + // another client. Silently deleting only the room atom would strand an + // open workspace and leave membership metadata half-cleaned. The normal + // local disband action performs the complete cross-module cleanup. + if (disbandedIds.size) { + for (const [name, room] of Object.entries($groupChats.get())) { + if ( + room.roomId && + disbandedIds.has(room.roomId) && + caughtUpDisbandedIds.has(room.roomId) && + room.hostedConnectionId === connectionId + ) { + updateGroupChat( + name, + current => ({ + ...current, + running: false, + hostedStatus: { + state: 'deleted', + label: botsText().group.hostedDeleted + }, + continuityIssue: botsText().group.hostedDeleteLocally + }), + { + sync: false + } + ) + } + } + } + + if (listComplete) { + hostedRoomInventoriedConnections.add(connectionId) + const listedIds = new Set(listedRooms.map(raw => String(record(raw)?.room_id || '')).filter(Boolean)) + + for (const [name, room] of Object.entries($groupChats.get())) { + const roomId = String(room?.roomId || '') + + if (!roomId || room.hostedConnectionId !== connectionId || listedIds.has(roomId)) { + continue + } + + try { + await requestHostedConnection(route, 'groups.state', { + room_id: roomId, + include_disbanded: true + }) + + continue + } catch (error) { + const message = String(record(error)?.message || record(record(error)?.error)?.message || error || '') + + if (!/history expired|permanently retired|hosted room not found/i.test(message)) { + continue + } + } + + hostedRoomPollCache.delete(roomId) + updateGroupChat( + name, + current => ({ + ...current, + running: false, + hostedStatus: { + state: 'deleted', + label: botsText().group.hostedDeleted + }, + continuityIssue: botsText().group.hostedDeleteLocally + }), + { sync: false } + ) + } + } + } + + if (!hostedRoomSyncDisposed) { + $hostedRoomCapabilities.set(capabilities) + } + } finally { + hostedRoomSyncRunning = false + } +} + +function scheduleHostedRoomSync(delay = HOSTED_ROOM_SYNC_INTERVAL_MS) { + if (hostedRoomSyncDisposed || typeof setTimeout !== 'function') { + return + } + + if (hostedRoomSyncTimer) { + clearTimeout(hostedRoomSyncTimer) + } + + hostedRoomSyncTimer = setTimeout(() => { + hostedRoomSyncTimer = null + void dispatchHostedRoomCleanup() + .catch(() => undefined) + .then(() => refreshHostedRooms()) + .catch(() => undefined) + .then(() => dispatchHostedRoomOutbox()) + .catch(() => undefined) + .then(() => scheduleHostedRoomSync()) + }, delay) + + const timer = hostedRoomSyncTimer as ReturnType & { unref?: () => void } + timer?.unref?.() +} + +async function transitionHostedRoomOutbox(action: Parameters[1]) { + if (typeof hostedRoomStorage?.set !== 'function') { + throw new Error(botsText().group.desktopStorageUnavailable) + } + + const previous = $hostedRoomOutbox.get() + const next = reduceHostedRoomOutbox(previous, action) + + $hostedRoomOutbox.set(next) + + try { + await hostedRoomStorage.set(HOSTED_ROOM_OUTBOX_KEY, next) + } catch (error) { + $hostedRoomOutbox.set(previous) + throw error + } + + return next +} + +const TERMINAL_HOSTED_ROOM_COMMAND_CODES = new Set([4110, 4111, 4113, 4117]) + +function terminalCommandFailure(error: unknown) { + const candidate = record(error) + const nested = record(candidate?.error) + const code = Number(candidate?.code ?? nested?.code) + + return Number.isInteger(code) && TERMINAL_HOSTED_ROOM_COMMAND_CODES.has(code) +} + +export async function dispatchHostedRoomOutbox() { + if (hostedOutboxDispatching || hostedRoomSyncDisposed) { + return + } + + hostedOutboxDispatching = true + + try { + let state = $hostedRoomOutbox.get() + + for (const command of state.commands.filter(entry => entry.status === 'pending')) { + const route = (await hostedDefaultRoutes()).find(candidate => candidate.connectionId === command.connectionId) + + if (hostedRoomSyncDisposed) { + return + } + + if (!route) { + continue + } + + state = await transitionHostedRoomOutbox({ + type: 'dispatch', + commandId: command.commandId + }) + + const method: Record = { + create: 'groups.create', + retry: 'groups.retry', + rename: 'groups.rename', + send: 'groups.send', + stop: 'groups.stop', + disband: 'groups.disband' + } + + const params = + command.kind === 'send' + ? { + room_id: command.roomId, + event_id: command.commandId, + payload: command.payload + } + : command.kind === 'rename' + ? { + room_id: command.roomId, + event_id: command.commandId, + name: command.payload.name + } + : command.kind === 'retry' + ? { + room_id: command.roomId, + task_id: command.payload.task_id + } + : command.kind === 'stop' || command.kind === 'disband' + ? { + room_id: command.roomId, + cancel_id: command.commandId + } + : command.payload + + try { + await requestHostedConnection(route, method[command.kind], params) + + // Keep the persisted in-flight command untouched when the window is + // disposed mid-request. Rehydration returns it to pending with the + // same idempotency key, covering an unknown server outcome safely. + if (hostedRoomSyncDisposed) { + return + } + + state = await transitionHostedRoomOutbox({ + type: 'acknowledge', + commandId: command.commandId + }) + } catch (error) { + state = await transitionHostedRoomOutbox( + terminalCommandFailure(error) + ? { + type: 'terminal-failure', + commandId: command.commandId, + failureCode: String(record(error)?.code || 'command-rejected') + } + : { + type: 'transient-failure', + commandId: command.commandId + } + ) + } + } + } finally { + hostedOutboxDispatching = false + } +} + +async function enqueueHostedRoomCommand(command: Partial) { + await transitionHostedRoomOutbox({ + type: 'enqueue', + command + }) + await dispatchHostedRoomOutbox() + + const pending = $hostedRoomOutbox.get().commands.find(entry => entry.commandId === command.commandId) + + if (pending?.status === 'failed') { + throw new Error(botsText().group.hostRejectedCommand) + } + + scheduleHostedRoomSync(0) + + return !pending +} + +async function hostedRouteForRoom(room: GroupChat) { + const connectionId = String(room?.hostedConnectionId || '') + const routes = await hostedDefaultRoutes() + + if (connectionId) { + const exact = routes.find(candidate => candidate.connectionId === connectionId) + + if (exact) { + return exact + } + } + + return hostedAuthorityRoutes.get(groupChatHostedGateway(room)) || null +} + +export async function probeHostedRoomMembers(members: GroupMember[]): Promise { + const routes = Object.fromEntries( + (await hostedDefaultRoutes()).map(route => [String(route.connectionId || ''), route]) + ) + + const connectionIds = [ + ...new Set( + (Array.isArray(members) ? members : []) + .map(member => String(member?.route?.connectionId || member?.connectionId || activeConnectionId() || '')) + .filter(Boolean) + ) + ] + + const capabilities: Record = {} + const now = Date.now() + + for (const connectionId of connectionIds) { + const cached = $hostedRoomCapabilities.get()[connectionId] + + if (cached?.kind === 'unsupported' && Number(hostedUnsupportedUntil.get(connectionId) || 0) > now) { + capabilities[connectionId] = cached + + continue + } + + const route = routes[connectionId] + let capability: HostedRoomCapability + + try { + capability = classifyHostedRoomCapability( + route + ? await withHostedRoomProbeTimeout(requestHostedConnection(route, 'groups.capabilities')) + : { ok: false, error: new Error('Gateway route unavailable') }, + { connectionId } + ) + } catch (error) { + capability = classifyHostedRoomCapability({ ok: false, error }, { connectionId }) + } + + capabilities[connectionId] = capability + + if (capability.kind === 'unsupported') { + hostedUnsupportedUntil.set(connectionId, now + HOSTED_ROOM_UNSUPPORTED_REPROBE_MS) + } else { + hostedUnsupportedUntil.delete(connectionId) + } + + if (capability.authorityId && isHostedRoomContinuityEligible(capability) && route) { + hostedAuthorityRoutes.set(capability.authorityId, route) + } + } + + $hostedRoomCapabilities.set({ ...$hostedRoomCapabilities.get(), ...capabilities }) + + const route = resolveAutonomousRoomPlan(members, { + activeConnectionId: activeConnectionId(), + capabilities + }) + + const capability = route.connectionId ? capabilities[route.connectionId] || null : null + + return { + route, + routes, + capabilities, + capability, + eligible: route.kind !== 'unsupported' && isHostedRoomContinuityEligible(capability) + } +} + +export async function createHostedGroupChat({ route, roomId, name, members }: HostedRoomCreateInput): Promise<{ + authorityEpoch: number + authorityId: string + connectionId: string +}> { + if ((route.kind !== 'single-gateway' && route.kind !== 'multi-gateway') || !route.connectionId) { + throw new Error(botsText().group.botsNeedOneHost) + } + + const profileRoute = (await hostedDefaultRoutes()).find(candidate => candidate.connectionId === route.connectionId) + + if (!profileRoute) { + throw new Error(botsText().group.hostRouteMissing) + } + + let room: Record | null = null + + try { + const result = await requestHostedConnection>(profileRoute, 'groups.create', { + room_id: roomId, + name, + members + }) + + room = record(result.room) + } catch (createError) { + // A dropped response has an unknown outcome. Verify the idempotent room id + // before falling back to Desktop, or both drivers could start the first + // user turn. A true create failure has no state and safely falls through. + try { + const state = await requestHostedConnection>(profileRoute, 'groups.state', { + room_id: roomId + }) + + room = record(state.room) + } catch { + throw createError + } + } + + const authorityId = String(room?.authority_gateway_id || '') + + if (!authorityId) { + throw new Error(botsText().group.hostRejectedCommand) + } + + hostedAuthorityRoutes.set(authorityId, profileRoute) + + return { + authorityId, + authorityEpoch: Math.max(1, Number(room?.authority_epoch || 1)), + connectionId: route.connectionId + } +} + +export async function createAutonomousHostedGroupChat({ + probe, + roomId, + name, + members +}: AutonomousHostedRoomCreateInput) { + const plan = probe.route + const homeConnectionId = String(plan.homeConnectionId || '') + const homeRoute = probe.routes[homeConnectionId] + const homeCapability = probe.capabilities[homeConnectionId] + + if (!probe.eligible || !homeConnectionId || !homeRoute || !homeCapability?.authorityId) { + throw new Error('This Group Chat cannot continue without Desktop yet.') + } + + const hostedMembers: Array> = [] + const peerRegistrations: Array> = [] + + try { + await addHostedRoomCleanup({ + operationId: `${roomId}:home-disband`, + setupId: roomId, + kind: 'home-disband', + connectionId: homeConnectionId, + roomId, + cancelId: `rollback-${roomId}` + }) + + for (const [index, item] of members.entries()) { + const connectionId = String(item.member.route?.connectionId || item.member.connectionId || '') + const profile = String(item.member.targetProfile || item.profile || item.member.name || 'default') + const memberId = `member-${index + 1}-${profile}`.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 128) + + const descriptor: Record = { + member_id: memberId, + profile, + handle: item.handle, + ...(item.displayName + ? { + display_name: item.displayName + } + : {}) + } + + if (connectionId === homeConnectionId) { + hostedMembers.push(descriptor) + + continue + } + + const invitation = record( + await requestForBot(item.member, 'groups.peer.invite', { + room_id: roomId, + home_install_id: homeCapability.authorityId, + authority_gateway_id: homeCapability.authorityId, + authority_epoch: 1, + member_id: memberId, + profile + }) + ) + + const catalog = record(invitation?.catalog) + const invitedProfile = String(invitation?.target_profile || profile || '') + + const scopedTargetUrl = profileScopedRoomLinkEndpoint( + probe.capabilities[connectionId]?.roomLink?.endpoint, + invitation?.target_profile + ) + + if (invitation?.grant && invitedProfile) { + await addHostedRoomCleanup({ + operationId: `${roomId}:peer-revoke:${memberId}`, + setupId: roomId, + kind: 'peer-revoke', + connectionId, + profile: invitedProfile, + grant: String(invitation.grant) + }) + } + + if ( + !scopedTargetUrl || + !invitation?.grant || + !catalog?.installation_id || + !catalog.catalog_digest || + !invitation.target_profile + ) { + throw new Error('One selected Bot could not prepare this Group Chat.') + } + + hostedMembers.push({ + ...descriptor, + profile: invitation.target_profile, + target: { + kind: 'peer', + peer_id: catalog.installation_id, + installation_id: catalog.installation_id, + profile: invitation.target_profile, + capability_digest: catalog.catalog_digest + } + }) + peerRegistrations.push({ + room_id: roomId, + member_id: memberId, + target_url: scopedTargetUrl, + target_profile: invitation.target_profile, + grant: invitation.grant, + catalog + }) + } + + const created = await createHostedGroupChat({ + route: plan, + roomId, + name, + members: hostedMembers as HostedRoomCreateInput['members'] + }) + + for (const registration of peerRegistrations) { + await requestHostedConnection(homeRoute, 'groups.peer.register', registration) + } + + await releaseHostedRoomCleanup(roomId) + + return { + ...created, + continuityMode: plan.kind === 'multi-gateway' ? ('distributed' as const) : ('gateway' as const) + } + } catch (error) { + await armHostedRoomCleanup(roomId).catch(() => undefined) + await dispatchHostedRoomCleanup().catch(() => undefined) + + if ( + normalizeHostedRoomCleanup($hostedRoomCleanup.get()).operations.some(operation => operation.setupId === roomId) + ) { + throw Object.assign( + new Error('Some selected Bots could not finish cleanup. Reconnect them before trying again.', { + cause: error + }), + { + fallbackSafe: false + } + ) + } + + throw error + } +} + +export async function sendHostedGroupChat(group: string, message: GroupMessage, thread: string) { + const room = $groupChats.get()[group] + + if (!room?.roomId || !groupChatHostedGateway(room)) { + throw new Error(botsText().group.hostRouteMissing) + } + + const route = await hostedRouteForRoom(room) + const connectionId = String(route?.connectionId || room.hostedConnectionId || '') + + if (!connectionId) { + throw new Error(botsText().group.hostRouteMissing) + } + + return enqueueHostedRoomCommand({ + commandId: String(message.id || ''), + kind: 'send', + roomId: room.roomId, + authorityId: groupChatHostedGateway(room), + connectionId, + payload: { + text: message.text || '', + thread_id: thread + } + }) +} + +export async function stopHostedGroupChat(group: string) { + const room = $groupChats.get()[group] + + if (!room?.roomId || !groupChatHostedGateway(room)) { + return false + } + + const route = await hostedRouteForRoom(room) + const connectionId = String(route?.connectionId || room.hostedConnectionId || '') + + if (!connectionId) { + throw new Error(botsText().group.hostRouteMissing) + } + + return enqueueHostedRoomCommand({ + commandId: crypto.randomUUID(), + kind: 'stop', + roomId: room.roomId, + authorityId: groupChatHostedGateway(room), + connectionId, + payload: {} + }) +} + +export async function retryHostedGroupChat(group: string, taskId: string) { + const room = $groupChats.get()[group] + + if (!room?.roomId || !groupChatHostedGateway(room) || !String(taskId || '').trim()) { + return false + } + + const route = await hostedRouteForRoom(room) + const connectionId = String(route?.connectionId || room.hostedConnectionId || '') + + if (!connectionId) { + throw new Error(botsText().group.hostRouteMissing) + } + + return enqueueHostedRoomCommand({ + commandId: crypto.randomUUID(), + kind: 'retry', + roomId: room.roomId, + authorityId: groupChatHostedGateway(room), + connectionId, + payload: { task_id: String(taskId).trim() } + }) +} + +/** Resume bounded history replay without retrying any Bot work. */ +export async function retryHostedRoomReplay(group: string) { + const room = $groupChats.get()[group] + const roomId = String(room?.roomId || '') + + if (!roomId || !groupChatHostedGateway(room)) { + return false + } + + hostedRoomPollCache.delete(roomId) + await refreshHostedRooms() + scheduleHostedRoomSync(0) + + return true +} + +export async function renameHostedGroupChat(group: string, name: string) { + const room = $groupChats.get()[group] + + if (!room?.roomId || !groupChatHostedGateway(room)) { + return true + } + + // A refresh may already be replaying the pre-rename server snapshot. Advance + // the room fence before the request so that stale replay cannot restore the + // old map key after the local rename completes or is queued for retry. + beginHostedRoomMutation(room.roomId) + + const route = await hostedRouteForRoom(room) + const connectionId = String(route?.connectionId || room.hostedConnectionId || '') + + if (!connectionId) { + throw new Error(botsText().group.hostRouteMissing) + } + + return enqueueHostedRoomCommand({ + commandId: crypto.randomUUID(), + kind: 'rename', + roomId: room.roomId, + authorityId: groupChatHostedGateway(room), + connectionId, + payload: { + name + } + }) +} + +export async function disbandHostedGroupChat(group: string) { + const room = $groupChats.get()[group] + + if (!room?.roomId || !groupChatHostedGateway(room)) { + return false + } + + const route = await hostedRouteForRoom(room) + + if (!route) { + throw new Error( + botsText().group.hostedReconnectToDelete( + sourceLabel(String(room.hostedConnectionId || '')) || botsText().group.thisHost + ) + ) + } + + return enqueueHostedRoomCommand({ + commandId: crypto.randomUUID(), + kind: 'disband', + roomId: room.roomId, + authorityId: groupChatHostedGateway(room), + connectionId: route.connectionId, + payload: {} + }) +} + +export async function startHostedRoomRuntime(storage: PluginContext['storage'], hooks: HostedRoomRuntimeHooks = {}) { + hostedRoomStorage = storage + hostedRoomHooks = hooks + hostedRoomSyncDisposed = false + hostedRoomMutationGenerations.clear() + hostedRoomLocallyDeleted.clear() + hostedRoomInventoriedConnections.clear() + let persisted: unknown = null + + try { + persisted = await storage?.get?.(HOSTED_ROOM_OUTBOX_KEY, null) + } catch { + /* an empty outbox is the safe fallback */ + } + + try { + $hostedRoomOutbox.set(createHostedRoomOutbox(persisted)) + } catch { + $hostedRoomOutbox.set(createHostedRoomOutbox()) + } + + try { + $hostedRoomCleanup.set(normalizeHostedRoomCleanup(await storage?.get?.(HOSTED_ROOM_CLEANUP_KEY, null))) + } catch { + $hostedRoomCleanup.set({ version: 1, operations: [] }) + } + + await dispatchHostedRoomCleanup().catch(() => undefined) + await refreshHostedRooms().catch(() => undefined) + await dispatchHostedRoomOutbox().catch(() => undefined) + scheduleHostedRoomSync() +} + +export function stopHostedRoomRuntime() { + hostedRoomSyncDisposed = true + hostedRoomStorage = null + hostedRoomHooks = {} + hostedAuthorityRoutes.clear() + hostedRoomPollCache.clear() + hostedRoomMutationGenerations.clear() + hostedRoomLocallyDeleted.clear() + hostedRoomInventoriedConnections.clear() + hostedUnsupportedUntil.clear() + + if (hostedRoomSyncTimer) { + clearTimeout(hostedRoomSyncTimer) + } + + hostedRoomSyncTimer = null +} + +/** Test-only lifecycle reset through the same public stop door. */ +export function resetHostedRoomRuntimeForTests() { + stopHostedRoomRuntime() + hostedRoomSyncRunning = false + hostedOutboxDispatching = false + hostedCleanupDispatching = false + $hostedRoomCapabilities.set({}) + $hostedRoomOutbox.set(createHostedRoomOutbox()) + $hostedRoomCleanup.set({ version: 1, operations: [] }) +} diff --git a/apps/desktop/src/plugins/hermes-bots/i18n.test.ts b/apps/desktop/src/plugins/hermes-bots/i18n.test.ts index d2d162d847776..8a1891785cc09 100644 --- a/apps/desktop/src/plugins/hermes-bots/i18n.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/i18n.test.ts @@ -67,4 +67,43 @@ describe('BOTS_LOCALES', () => { expect(reasonFn(sentinel)).toContain(sentinel) } }) + + it('keeps automatic continuity copy concise and free of gateway jargon', () => { + const byPath = Object.fromEntries(leafEntries(en!)) + const copy = (path: string) => byPath[`group.${path}`] as (value: string) => string + const text = (path: string) => byPath[`group.${path}`] as string + + const samples = [ + copy('hostedFallbackToDesktop')('Studio'), + copy('hostedQueued')('Studio'), + copy('hostedQueuedHint')('Studio'), + copy('hostedSendFailed')('Studio'), + copy('hostedRenameQueued')('Studio'), + copy('hostedRenameFailed')('Studio'), + copy('hostUpdateNeeded')('Studio'), + copy('hostReconnectToContinue')('Studio'), + copy('hostedReconnectToStop')('Studio'), + copy('hostedReconnectToDelete')('Studio'), + text('hostedSending'), + text('hostedWorking'), + text('hostedNeedsAttention'), + text('hostedStopping'), + text('hostedStopped'), + text('hostedDeleted'), + text('hostedDeleteLocally'), + text('hostedMembersFixed'), + text('hostRouteMissing'), + text('hostedSyncing'), + text('botsNeedOneHost'), + text('desktopStorageUnavailable'), + text('hostRejectedCommand') + ] + + expect(byPath).not.toHaveProperty('group.keepRunningTitle') + + for (const sample of samples) { + expect(sample).not.toMatch(/gateway/i) + expect(sample.length).toBeLessThanOrEqual(110) + } + }) }) diff --git a/apps/desktop/src/plugins/hermes-bots/i18n.ts b/apps/desktop/src/plugins/hermes-bots/i18n.ts index 3c8e74a7f70a4..5a12f14f1f7c0 100644 --- a/apps/desktop/src/plugins/hermes-bots/i18n.ts +++ b/apps/desktop/src/plugins/hermes-bots/i18n.ts @@ -90,6 +90,7 @@ type BotsMessages = { duplicateFailed: string deleteTitle: string removeFromAllGroups: string + removeFromOtherGroups: string createFirstHint: string createFailed: string advanced: string @@ -130,6 +131,8 @@ type BotsMessages = { /** Group chats: the room, its composer, threads and activity feed. */ group: { newTitle: string + newDesc: string + noBots: string manageDesc: string manageTitle: string settingsTitle: string @@ -161,6 +164,51 @@ type BotsMessages = { holdReleaseHint: string needsYourInput: string pictureGenerationFailed: string + createAction: (count: number) => string + created: (name: string, count: number) => string + createFailed: string + creating: string + pickAtLeastTwo: string + thisHost: string + hostedFallbackToDesktop: (host: string) => string + hostedAttachmentsUnavailable: string + hostedSending: string + hostedWorking: string + hostedQueued: (host: string) => string + hostedQueuedHint: (host: string) => string + hostedNeedsAttention: string + hostedSendFailed: (host: string) => string + hostedStopping: string + hostedStopped: string + hostedStopQueued: (host: string) => string + hostedStopQueuedHint: (host: string) => string + hostedUnavailable: (host: string) => string + hostedReconnectToStop: (host: string) => string + hostedDeleted: string + hostedDeleteLocally: string + hostedMembersFixed: string + hostedRenameQueued: (host: string) => string + hostedRenameFailed: (host: string) => string + hostRouteMissing: string + hostUpdateNeeded: (host: string) => string + hostReconnectToContinue: (host: string) => string + hostedReconnectToDelete: (host: string) => string + hostedSyncing: string + continuityOnTitle: string + continuityOnDesc: string + continuityDesktopTitle: string + continuityDesktopDesc: string + retryTitle: string + retryDesc: string + retryAction: string + botsNeedOneHost: string + aBot: string + memberUnavailable: (member: string) => string + memberNeedsAttention: (member: string) => string + memberCouldNotRespond: (member: string) => string + memberRetryWhenOnline: (member: string) => string + desktopStorageUnavailable: string + hostRejectedCommand: string nameTaken: (name: string) => string memberCount: (count: number) => string settingsHint: (group: string) => string @@ -296,6 +344,7 @@ const en: BotsMessages = { duplicateFailed: 'Duplicate failed', deleteTitle: 'Delete bot and profile?', removeFromAllGroups: 'Remove from all groups', + removeFromOtherGroups: 'Leave other groups', createFirstHint: 'Open the Bots pane and hit “New Bot”.', createFailed: 'Could not create the profile yet', advanced: 'Advanced', @@ -331,7 +380,9 @@ const en: BotsMessages = { }, group: { newTitle: 'New group chat', - manageDesc: 'A bot can join multiple group chats. Memberships sync to every machine.', + newDesc: 'Choose 2–6 Bots.', + noBots: 'No bots yet. Create a bot first.', + manageDesc: 'A Bot can join more than one Group Chat.', manageTitle: 'Manage groups', settingsTitle: 'Group settings', settingsDesc: 'Rename the group or set a room picture. Members and history are kept.', @@ -362,6 +413,52 @@ const en: BotsMessages = { holdReleaseHint: 'Mention a paused bot or send @all resume to release them.', needsYourInput: 'A bot in this group chat needs your input', pictureGenerationFailed: 'Group picture generation failed', + createAction: count => `Create Group${count ? ` (${count})` : ''}`, + created: (name, count) => `“${name}” created with ${count} bots`, + createFailed: 'Could not create the Group Chat. Try again.', + creating: 'Creating…', + pickAtLeastTwo: 'Pick at least 2 bots', + thisHost: 'this device', + hostedFallbackToDesktop: host => + `${host} can't keep this Group Chat running yet. Keep Desktop open.`, + hostedAttachmentsUnavailable: 'Attachments need Desktop mode for now.', + hostedSending: 'Sending…', + hostedWorking: 'Working', + hostedQueued: host => `Waiting for ${host}`, + hostedQueuedHint: host => `Saved. It will send when ${host} is online.`, + hostedNeedsAttention: 'Needs attention', + hostedSendFailed: host => `Not sent. Reconnect ${host} and retry.`, + hostedStopping: 'Stopping…', + hostedStopped: 'Stopped', + hostedStopQueued: host => `Stop requested. It will stop when ${host} is online.`, + hostedStopQueuedHint: host => `It will stop when ${host} is online.`, + hostedUnavailable: host => `${host} is offline`, + hostedReconnectToStop: host => `Reconnect ${host} to stop this Group Chat.`, + hostedDeleted: 'This Group Chat was deleted.', + hostedDeleteLocally: 'Delete it here to remove its local membership and history.', + hostedMembersFixed: 'Members cannot change while this Group Chat keeps running without Desktop.', + hostedRenameQueued: host => `Rename saved. It will sync when ${host} is online.`, + hostedRenameFailed: host => `Could not rename. Reconnect ${host} and retry.`, + hostRouteMissing: 'This Group Chat connection is unavailable.', + hostUpdateNeeded: host => `Update ${host} to keep this Group Chat running.`, + hostReconnectToContinue: host => `Reconnect ${host} to continue.`, + hostedReconnectToDelete: host => `Reconnect ${host} to delete this Group Chat.`, + hostedSyncing: 'Syncing recent activity…', + continuityOnTitle: 'Works without Desktop', + continuityOnDesc: 'Bots can continue while Desktop is closed.', + continuityDesktopTitle: 'Keep Desktop open', + continuityDesktopDesc: 'Bots pause when Desktop closes.', + retryTitle: 'Retry uncertain work?', + retryDesc: 'The earlier attempt may have finished. Retrying could repeat actions.', + retryAction: 'Retry', + botsNeedOneHost: 'The selected Bots cannot continue when Desktop is closed.', + aBot: 'A bot', + memberUnavailable: member => `${member} is unavailable.`, + memberNeedsAttention: member => `${member} needs your attention.`, + memberCouldNotRespond: member => `${member} could not respond.`, + memberRetryWhenOnline: member => `${member} will retry when online.`, + desktopStorageUnavailable: 'Desktop could not save this action. Try again.', + hostRejectedCommand: 'The connected device rejected this action.', nameTaken: name => `A group named “${name}” already exists.`, memberCount: count => `${count} bots`, settingsHint: group => `Group settings — rename ${group} or set a room picture`, @@ -490,6 +587,7 @@ const ja: BotsMessages = { duplicateFailed: '複製に失敗しました', deleteTitle: 'ボットとプロファイルを削除しますか?', removeFromAllGroups: 'すべてのグループから外す', + removeFromOtherGroups: 'ほかのグループから外す', createFirstHint: 'ボットパネルを開いて「新しいボット」を押してください。', createFailed: 'プロファイルをまだ作成できませんでした', advanced: '詳細設定', @@ -525,7 +623,9 @@ const ja: BotsMessages = { }, group: { newTitle: '新しいグループチャット', - manageDesc: 'ボットは複数のグループチャットに参加できます。メンバーシップはすべてのマシンに同期されます。', + newDesc: '2〜6体のボットを選択してください。', + noBots: 'ボットがまだありません。先にボットを作成してください。', + manageDesc: 'ボットは複数のグループチャットに参加できます。', manageTitle: 'グループを管理', settingsTitle: 'グループ設定', settingsDesc: 'グループ名の変更や部屋の画像の設定ができます。メンバーと履歴は保持されます。', @@ -556,6 +656,52 @@ const ja: BotsMessages = { holdReleaseHint: '一時停止中のボットにメンションするか、@all resume を送信して再開します。', needsYourInput: 'このグループチャットのボットが入力を待っています', pictureGenerationFailed: 'グループ画像の生成に失敗しました', + createAction: count => `グループを作成${count ? ` (${count})` : ''}`, + created: (name, count) => `「${name}」を${count}体のボットで作成しました`, + createFailed: 'グループチャットを作成できませんでした。もう一度お試しください。', + creating: '作成中…', + pickAtLeastTwo: '2体以上のボットを選択してください', + thisHost: 'このデバイス', + hostedFallbackToDesktop: host => + `${host} ではまだこのグループチャットを継続できません。Desktopを開いたままにしてください。`, + hostedAttachmentsUnavailable: '現在、添付ファイルにはDesktopモードが必要です。', + hostedSending: '送信中…', + hostedWorking: '作業中', + hostedQueued: host => `${host} を待っています`, + hostedQueuedHint: host => `保存しました。${host} がオンラインになると送信されます。`, + hostedNeedsAttention: '確認が必要です', + hostedSendFailed: host => `送信できませんでした。${host} を再接続して再試行してください。`, + hostedStopping: '停止中…', + hostedStopped: '停止しました', + hostedStopQueued: host => `${host} への停止を保存しました`, + hostedStopQueuedHint: host => `${host} がオンラインになると停止します。`, + hostedUnavailable: host => `${host} はオフラインです`, + hostedReconnectToStop: host => `このグループチャットを停止するには ${host} を再接続してください。`, + hostedDeleted: 'このグループチャットは削除されました。', + hostedDeleteLocally: 'ローカルのメンバーシップと履歴を削除するには、ここで削除してください。', + hostedMembersFixed: 'Desktopなしで実行中のグループチャットではメンバーを変更できません。', + hostedRenameQueued: host => `名前変更を保存しました。${host} がオンラインになると同期されます。`, + hostedRenameFailed: host => `名前を変更できませんでした。${host} を再接続して再試行してください。`, + hostRouteMissing: 'このグループチャットの接続を利用できません。', + hostUpdateNeeded: host => `継続実行するには ${host} を更新してください。`, + hostReconnectToContinue: host => `続行するには ${host} を再接続してください。`, + hostedReconnectToDelete: host => `このグループチャットを削除するには ${host} を再接続してください。`, + hostedSyncing: '最近のアクティビティを同期中…', + continuityOnTitle: 'Desktopを閉じても大丈夫です', + continuityOnDesc: 'このグループチャットのボットは作業を続けます。', + continuityDesktopTitle: 'Desktopを開いたままにしてください', + continuityDesktopDesc: 'Desktopを閉じると、このグループチャットは一時停止します。', + retryTitle: '不確かな作業を再試行しますか?', + retryDesc: '前の試行が完了している可能性があります。再試行すると操作が重複する場合があります。', + retryAction: '再試行', + botsNeedOneHost: '選択したボットはDesktopを閉じると継続できません。', + aBot: 'ボット', + memberUnavailable: member => `${member} は利用できません。`, + memberNeedsAttention: member => `${member} に確認が必要です。`, + memberCouldNotRespond: member => `${member} は応答できませんでした。`, + memberRetryWhenOnline: member => `${member} はオンラインになると再試行します。`, + desktopStorageUnavailable: 'Desktopでこの操作を保存できませんでした。もう一度お試しください。', + hostRejectedCommand: '接続先がこの操作を拒否しました。', nameTaken: name => `「${name}」という名前のグループはすでに存在します。`, memberCount: count => `ボット${count}体`, settingsHint: group => `グループ設定 — ${group}の名前変更やルーム画像の設定`, @@ -683,6 +829,7 @@ const zh: BotsMessages = { duplicateFailed: '复制失败', deleteTitle: '删除机器人和配置档案?', removeFromAllGroups: '从所有群组中移除', + removeFromOtherGroups: '退出其他群组', createFirstHint: '打开机器人面板,点击“新建机器人”。', createFailed: '暂时无法创建配置档案', advanced: '高级', @@ -718,7 +865,9 @@ const zh: BotsMessages = { }, group: { newTitle: '新建群聊', - manageDesc: '一个机器人可以加入多个群聊。成员关系会同步到每台设备。', + newDesc: '选择 2–6 个机器人。', + noBots: '还没有机器人。请先创建一个机器人。', + manageDesc: '一个机器人可以加入多个群聊。', manageTitle: '管理群组', settingsTitle: '群组设置', settingsDesc: '重命名群组或设置房间图片。成员和历史都会保留。', @@ -749,6 +898,51 @@ const zh: BotsMessages = { holdReleaseHint: '提及已暂停的机器人,或发送 @all resume 以恢复它们。', needsYourInput: '此群聊中有机器人需要你输入', pictureGenerationFailed: '群组图片生成失败', + createAction: count => `创建群聊${count ? ` (${count})` : ''}`, + created: (name, count) => `已创建“${name}”,包含 ${count} 个机器人`, + createFailed: '无法创建群聊。请重试。', + creating: '正在创建…', + pickAtLeastTwo: '请至少选择 2 个机器人', + thisHost: '此设备', + hostedFallbackToDesktop: host => `${host} 暂时无法保持此群聊运行。请保持 Desktop 打开。`, + hostedAttachmentsUnavailable: '附件目前需要 Desktop 模式。', + hostedSending: '正在发送…', + hostedWorking: '正在工作', + hostedQueued: host => `正在等待 ${host}`, + hostedQueuedHint: host => `已保存。${host} 上线后将发送。`, + hostedNeedsAttention: '需要处理', + hostedSendFailed: host => `未发送。请重新连接 ${host} 后重试。`, + hostedStopping: '正在停止…', + hostedStopped: '已停止', + hostedStopQueued: host => `已为 ${host} 保存停止请求`, + hostedStopQueuedHint: host => `${host} 上线后将停止。`, + hostedUnavailable: host => `${host} 已离线`, + hostedReconnectToStop: host => `请重新连接 ${host} 以停止此群聊。`, + hostedDeleted: '此群聊已被删除。', + hostedDeleteLocally: '请在此处删除,以移除本地成员关系和历史记录。', + hostedMembersFixed: '此群聊在没有 Desktop 的情况下运行时无法更改成员。', + hostedRenameQueued: host => `重命名已保存。${host} 上线后将同步。`, + hostedRenameFailed: host => `无法重命名。请重新连接 ${host} 后重试。`, + hostRouteMissing: '此群聊连接不可用。', + hostUpdateNeeded: host => `请更新 ${host} 以保持此群聊运行。`, + hostReconnectToContinue: host => `请重新连接 ${host} 以继续。`, + hostedReconnectToDelete: host => `请重新连接 ${host} 以删除此群聊。`, + hostedSyncing: '正在同步近期活动…', + continuityOnTitle: '可以关闭 Desktop', + continuityOnDesc: '此群聊中的机器人会继续工作。', + continuityDesktopTitle: '请保持 Desktop 打开', + continuityDesktopDesc: '关闭 Desktop 会暂停此群聊。', + retryTitle: '重试状态不确定的工作?', + retryDesc: '之前的尝试可能已完成。重试可能会重复操作。', + retryAction: '重试', + botsNeedOneHost: '关闭 Desktop 后,所选机器人无法继续工作。', + aBot: '一个机器人', + memberUnavailable: member => `${member} 不可用。`, + memberNeedsAttention: member => `${member} 需要你的处理。`, + memberCouldNotRespond: member => `${member} 无法回复。`, + memberRetryWhenOnline: member => `${member} 上线后将重试。`, + desktopStorageUnavailable: 'Desktop 无法保存此操作。请重试。', + hostRejectedCommand: '连接的设备拒绝了此操作。', nameTaken: name => `已存在名为“${name}”的群聊。`, memberCount: count => `${count} 个机器人`, settingsHint: group => `群聊设置 — 重命名 ${group} 或设置房间图片`, @@ -876,6 +1070,7 @@ const zhHant: BotsMessages = { duplicateFailed: '複製失敗', deleteTitle: '刪除機器人和設定檔?', removeFromAllGroups: '從所有群組中移除', + removeFromOtherGroups: '退出其他群組', createFirstHint: '開啟機器人面板,點「新增機器人」。', createFailed: '暫時無法建立設定檔', advanced: '進階', @@ -911,7 +1106,9 @@ const zhHant: BotsMessages = { }, group: { newTitle: '新增群組聊天', - manageDesc: '一個機器人可以加入多個群組聊天。成員關係會同步到每台裝置。', + newDesc: '選擇 2–6 個機器人。', + noBots: '還沒有機器人。請先建立一個機器人。', + manageDesc: '一個機器人可以加入多個群組聊天。', manageTitle: '管理群組', settingsTitle: '群組設定', settingsDesc: '重新命名群組或設定房間圖片。成員和歷史都會保留。', @@ -942,6 +1139,51 @@ const zhHant: BotsMessages = { holdReleaseHint: '提及已暫停的機器人,或傳送 @all resume 以恢復它們。', needsYourInput: '此群組聊天中有機器人需要您的輸入', pictureGenerationFailed: '群組圖片產生失敗', + createAction: count => `建立群組聊天${count ? ` (${count})` : ''}`, + created: (name, count) => `已建立「${name}」,包含 ${count} 個機器人`, + createFailed: '無法建立群組聊天。請再試一次。', + creating: '正在建立…', + pickAtLeastTwo: '請至少選擇 2 個機器人', + thisHost: '此裝置', + hostedFallbackToDesktop: host => `${host} 暫時無法保持此群組聊天運作。請保持 Desktop 開啟。`, + hostedAttachmentsUnavailable: '附件目前需要 Desktop 模式。', + hostedSending: '正在傳送…', + hostedWorking: '正在工作', + hostedQueued: host => `正在等待 ${host}`, + hostedQueuedHint: host => `已儲存。${host} 上線後將傳送。`, + hostedNeedsAttention: '需要處理', + hostedSendFailed: host => `未傳送。請重新連接 ${host} 後再試一次。`, + hostedStopping: '正在停止…', + hostedStopped: '已停止', + hostedStopQueued: host => `已為 ${host} 儲存停止要求`, + hostedStopQueuedHint: host => `${host} 上線後將停止。`, + hostedUnavailable: host => `${host} 已離線`, + hostedReconnectToStop: host => `請重新連接 ${host} 以停止此群組聊天。`, + hostedDeleted: '此群組聊天已被刪除。', + hostedDeleteLocally: '請在此處刪除,以移除本機成員關係和歷史記錄。', + hostedMembersFixed: '此群組聊天在沒有 Desktop 的情況下運作時無法變更成員。', + hostedRenameQueued: host => `重新命名已儲存。${host} 上線後將同步。`, + hostedRenameFailed: host => `無法重新命名。請重新連接 ${host} 後再試一次。`, + hostRouteMissing: '此群組聊天連線無法使用。', + hostUpdateNeeded: host => `請更新 ${host} 以保持此群組聊天運作。`, + hostReconnectToContinue: host => `請重新連接 ${host} 以繼續。`, + hostedReconnectToDelete: host => `請重新連接 ${host} 以刪除此群組聊天。`, + hostedSyncing: '正在同步近期活動…', + continuityOnTitle: '可以關閉 Desktop', + continuityOnDesc: '此群組聊天中的機器人會繼續工作。', + continuityDesktopTitle: '請保持 Desktop 開啟', + continuityDesktopDesc: '關閉 Desktop 會暫停此群組聊天。', + retryTitle: '重試狀態不確定的工作?', + retryDesc: '先前的嘗試可能已完成。重試可能會重複操作。', + retryAction: '重試', + botsNeedOneHost: '關閉 Desktop 後,所選機器人無法繼續工作。', + aBot: '一個機器人', + memberUnavailable: member => `${member} 無法使用。`, + memberNeedsAttention: member => `${member} 需要您的處理。`, + memberCouldNotRespond: member => `${member} 無法回覆。`, + memberRetryWhenOnline: member => `${member} 上線後將重試。`, + desktopStorageUnavailable: 'Desktop 無法儲存此操作。請再試一次。', + hostRejectedCommand: '已連接的裝置拒絕了此操作。', nameTaken: name => `已存在名為「${name}」的群組聊天。`, memberCount: count => `${count} 個機器人`, settingsHint: group => `群組設定 — 重新命名 ${group} 或設定房間圖片`, diff --git a/apps/desktop/src/plugins/hermes-bots/plugin-panes.test.tsx b/apps/desktop/src/plugins/hermes-bots/plugin-panes.test.tsx index 38fd1a883a5c4..263b888a49c71 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin-panes.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/plugin-panes.test.tsx @@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => ({ botChatOwnsWorkspace: vi.fn(() => false), paneVisibility: vi.fn(), sessionOwnsWorkspace: vi.fn(() => false), + startHostedRoomRuntime: vi.fn(async () => undefined), + stopHostedRoomRuntime: vi.fn(), setWorkspaceScope: vi.fn() })) @@ -49,10 +51,15 @@ vi.mock('@hermes/plugin-sdk', async importOriginal => { // storage sweeps and the panes' own render trees. vi.mock('./avatar', () => ({ startFaceClock: vi.fn(), stopFaceClock: vi.fn() })) vi.mock('./relay', () => ({ startBotRelay: vi.fn(), stopBotRelay: vi.fn() })) +vi.mock('./hosted-room-runtime', () => ({ + startHostedRoomRuntime: mocks.startHostedRoomRuntime, + stopHostedRoomRuntime: mocks.stopHostedRoomRuntime +})) vi.mock('./session-sweep', () => ({ startHideSweepScheduler: vi.fn() })) vi.mock('./canonical-chat', () => ({ openBotCanonicalChat: vi.fn() })) vi.mock('./chat-empty', () => ({ BotChatEmpty: () => null })) vi.mock('./hygiene', () => ({ annotateOrphanedGroupChatMembers: () => ({ changed: false, rooms: {} }) })) +vi.mock('./group-chat-view', () => ({ renameGroupChat: vi.fn(async (_old, next) => next) })) vi.mock('./cron', () => ({ bindProfileSync: () => () => undefined, RoutinesPane: () => null })) vi.mock('./roster-pane', () => ({ botChatOwnsWorkspace: mocks.botChatOwnsWorkspace, @@ -97,7 +104,7 @@ interface Registration { } /** A recording `PluginContext`: registrations, their disposers, teardown. */ -function recordingContext() { +function recordingContext(storageGet: (key: string) => Promise = async () => undefined) { const disposers: (() => void)[] = [] const registrations: Registration[] = [] const unregisters = new Map void>() @@ -116,7 +123,7 @@ function recordingContext() { return unregister }, - storage: { get: async () => undefined, set: async () => undefined } + storage: { get: storageGet, set: async () => undefined } } return { @@ -180,6 +187,39 @@ describe('the Bots pane dock', () => { }) }) +describe('hosted Group Chat startup', () => { + it('does not probe or replay hosted rooms before local Group Chat hydration settles', async () => { + paneStores() + let releaseRooms: (value: unknown) => void = () => undefined + + const rooms = new Promise(resolve => { + releaseRooms = resolve + }) + + const harness = recordingContext(async key => (key === 'group-chats' ? rooms : undefined)) + + plugin.register(harness.ctx) + await Promise.resolve() + await Promise.resolve() + + expect(mocks.startHostedRoomRuntime).not.toHaveBeenCalled() + + releaseRooms({}) + await settle() + + expect(mocks.startHostedRoomRuntime).toHaveBeenCalledTimes(1) + expect(mocks.startHostedRoomRuntime).toHaveBeenCalledWith( + harness.ctx.storage, + expect.objectContaining({ + renameGroupChat: expect.any(Function) + }) + ) + + harness.dispose() + expect(mocks.stopHostedRoomRuntime).toHaveBeenCalled() + }) +}) + describe('the Scheduled jobs pane', () => { it('stays unregistered until a bot chat owns the workspace', async () => { const store = paneStores() diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.tsx b/apps/desktop/src/plugins/hermes-bots/plugin.tsx index 577d7db2e8e49..26bc2f43f8623 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.tsx +++ b/apps/desktop/src/plugins/hermes-bots/plugin.tsx @@ -54,7 +54,9 @@ import { sweepGroupChatMembersForRemovedConnection, updateGroupChat } from './group-chat' +import { renameGroupChat } from './group-chat-view' import { groupWorkspaceOwnerKey } from './group-membership' +import { startHostedRoomRuntime, stopHostedRoomRuntime } from './hosted-room-runtime' import { annotateOrphanedGroupChatMembers } from './hygiene' import { BOTS_LOCALES } from './i18n' import { displayName } from './labels' @@ -97,6 +99,33 @@ export default { setPluginCtx(ctx) const disposeLocales = ctx.i18n.register(BOTS_LOCALES) setGroupChatSyncDisposed(false) + let roomServicesStarted = false + let roomServicesDisposed = false + let unbindGatewayListener: null | (() => void) = null + + const startRoomServices = () => { + if (roomServicesStarted || roomServicesDisposed) { + return + } + + roomServicesStarted = true + let bindingGatewayListener = true + unbindGatewayListener = host.state.gateway.listen(() => { + // Atom listeners seed synchronously. Treating that seed as a gateway + // transition bumps every room epoch and can cancel a startup send. + if (!bindingGatewayListener) { + handleSessionsGatewayTransition() + } + }) + bindingGatewayListener = false + void startHostedRoomRuntime(ctx.storage, { + renameGroupChat: (oldName, newName, members) => + renameGroupChat(oldName, newName, members, { + hostedAlreadyRenamed: true + }) + }) + } + startFaceClock() // The cross-connection relay rides every gateway socket this Desktop // holds: roster sync + envelope drain/deliver/reply loops. @@ -108,6 +137,10 @@ export default { ctx.onDispose(disposeLocales) ctx.onDispose(stopFaceClock) ctx.onDispose(stopBotRelay) + ctx.onDispose(() => { + roomServicesDisposed = true + stopHostedRoomRuntime() + }) } // @-mention autocomplete: typing "@rese…" in ANY composer offers the @@ -215,8 +248,9 @@ export default { /* no storage — default (silent) stays */ } - // Hydrate persisted group-chat room logs (epoch/running are runtime-only - // and always reset — a loop can't survive a window reload anyway). + // Hydrate persisted group-chat room logs. Desktop epochs/running are + // runtime-only; hosted authority/cursor fields survive so the gateway + // driver can keep working while this window is gone and replay safely. try { // @ts-expect-error TODO(bot-mode-types): PluginStorage.get requires a fallback argument. Promise.resolve(ctx.storage?.get?.('group-chats')) @@ -240,6 +274,14 @@ export default { holds: room.holds && typeof room.holds === 'object' ? room.holds : {}, members: Array.isArray(room.members) ? room.members : [], roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + hosted: typeof room.hosted === 'string' && room.hosted ? room.hosted : null, + hostedEpoch: Math.max(0, Number(room.hostedEpoch || 0)) || null, + hostedConnectionId: + typeof room.hostedConnectionId === 'string' && room.hostedConnectionId + ? room.hostedConnectionId + : null, + hostedSeq: Math.max(0, Number(room.hostedSeq || 0)), + continuityMode: room.hosted ? 'gateway' : room.continuityMode === 'gateway' ? 'gateway' : 'desktop', image: typeof room.image === 'string' && room.image ? room.image : null, syncRevision: Math.max(0, Number(room.syncRevision || 0)), epoch: 0, @@ -294,8 +336,10 @@ export default { scheduleGroupChatServerSync($groupChats.get()) }) .catch(() => undefined) + .finally(startRoomServices) } catch { /* no storage — rooms start empty */ + startRoomServices() } // Routines follow the chat you're in: track the focused chat's owner @@ -307,8 +351,6 @@ export default { // duplicate listener per cycle (same survives-disable class as the face // clock before its onDispose hook — these kept firing until app restart). const unbindProfileListener = bindProfileSync($focusedBotOwner) - const unbindGatewayListener = host.state.gateway.listen(handleSessionsGatewayTransition) - // #93492 root fix: the registry pushes a lifecycle event when a // connection is removed. The gateway store already disposes the dead // sockets; the persisted group-chat rosters referencing that connection diff --git a/apps/desktop/src/plugins/hermes-bots/types.ts b/apps/desktop/src/plugins/hermes-bots/types.ts index e21298f544b28..97be0b2253e17 100644 --- a/apps/desktop/src/plugins/hermes-bots/types.ts +++ b/apps/desktop/src/plugins/hermes-bots/types.ts @@ -138,9 +138,13 @@ export interface GroupMessageAuthor { export interface GroupMessage { /** Milliseconds. */ at: number + /** Stable gateway event identity after a hosted-room replay. */ + eventId?: string from: GroupMessageAuthor id?: string images?: Attachment[] + /** Monotonic gateway order for hosted-room events. */ + seq?: number text: string /** Messages predating threading carry the sentinel thread `'legacy'`. */ thread?: string @@ -152,6 +156,8 @@ export interface GroupHold { } export interface GroupChat { + /** User-facing continuity choice. Missing records are classic Desktop rooms. */ + continuityMode?: 'desktop' | 'distributed' | 'gateway' /** Bumped to abandon in-flight member turns from a previous round. */ epoch?: number holds?: Record @@ -161,6 +167,23 @@ export interface GroupChat { /** Immutable identity, so a rename doesn't fork the room. */ roomId?: null | string running?: boolean + /** Stable authority installation id for a gateway-hosted room. */ + hosted?: null | string + /** The local Desktop connection that currently reaches the authority. */ + hostedConnectionId?: null | string + /** Server-issued fencing epoch for the hosted authority. */ + hostedEpoch?: null | number + /** Last contiguous hosted-room event sequence applied locally. */ + hostedSeq?: number + hostedStatus?: null | { + canRetry?: boolean + canStop?: boolean + label: string + state: string + taskId?: string + } + /** Short, actionable continuity problem for the room surface. */ + continuityIssue?: null | string /** The immutable owner descriptor captured beside each plumbing session, * keyed the same way as `sessions`. Partial: legacy records hold a bare * `{ name }`, and the sweep re-validates the route before trusting one. */ From 13115dd95f92a9ade9d88ce7c1c03b704187fa30 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:50:39 +0200 Subject: [PATCH 04/16] fix(bot-mode): reconnect Group Chat peers safely Surface stale peer authorization and journal exact-grant cleanup across Desktop restarts. --- .../plugins/hermes-bots/group-chat-view.tsx | 32 + .../group-continuity-creation.test.tsx | 15 +- .../hermes-bots/hosted-room-cleanup.test.ts | 542 +++++++++++++++ .../hermes-bots/hosted-room-cleanup.ts | 602 +++++++++++++++++ .../hermes-bots/hosted-room-client.test.ts | 18 + .../plugins/hermes-bots/hosted-room-client.ts | 14 + .../hosted-room-reauthorization.test.ts | 631 ++++++++++++++++++ .../hosted-room-reauthorization.ts | 305 +++++++++ .../hosted-room-reconnect-runtime.test.ts | 602 +++++++++++++++++ .../hermes-bots/hosted-room-runtime.ts | 406 +++++------ apps/desktop/src/plugins/hermes-bots/i18n.ts | 20 + apps/desktop/src/plugins/hermes-bots/types.ts | 2 + gateway/hosted_room_contract.py | 1 + gateway/hosted_room_peer.py | 9 +- gateway/hosted_room_storage.py | 64 ++ gateway/hosted_rooms.py | 1 + .../test_hosted_room_exact_grant_revoke.py | 51 ++ tests/gateway/test_hosted_room_peer.py | 20 + tests/tui_gateway/test_groups_methods.py | 15 + .../test_hosted_room_grant_fingerprint.py | 116 ++++ tui_gateway/hosted_room_service.py | 180 +++-- tui_gateway/methods_groups.py | 60 +- 22 files changed, 3390 insertions(+), 316 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-reconnect-runtime.test.ts create mode 100644 tests/gateway/test_hosted_room_exact_grant_revoke.py create mode 100644 tests/tui_gateway/test_hosted_room_grant_fingerprint.py diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx index b386a8ec6b309..8acae19bfde2a 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx @@ -98,6 +98,7 @@ import { import type { GroupComposerDraft, GroupDraftSetter } from './group-panes' import { sendToGroupChat, stopGroupThread } from './group-rounds' import { clearGroupClarify } from './group-turns' +import { reconnectHostedGroupChatPeer } from './hosted-room-reauthorization' import { beginHostedRoomMutation, disbandHostedGroupChat, @@ -756,6 +757,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G // Collapsible Activity view: collapsed by default — opening it is always an // explicit user action, it never steals focus, and it never auto-scrolls. const [activityOpen, setActivityOpen] = useState(false) + const [reconnecting, setReconnecting] = useState(false) // Subscribe: activity rows re-render as turn events land. useValue($groupActivity) // Pending member questions for THIS room (#90694), oldest first. @@ -840,6 +842,25 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G const latestActivity = activityEvents.length ? activityEvents[activityEvents.length - 1] : null const hostedActivity = groupChatHostedGateway(room) ? room.hostedStatus?.label : null const retryTaskId = String(room.hostedStatus?.taskId || '') + const reconnectMemberId = String(room.hostedStatus?.reconnectMemberId || '') + + const reconnectRoomMember = async () => { + if (!reconnectMemberId || reconnecting) { + return + } + + setReconnecting(true) + try { + await reconnectHostedGroupChatPeer(group, reconnectMemberId) + } catch { + host.notify({ + kind: 'error', + message: b.group.reconnectFailed + }) + } finally { + setReconnecting(false) + } + } // #94570 shell rewired onto the real primitive (#91868/#94569): the button // must stop the ROUND, not just spray per-member interrupts — without the @@ -899,6 +920,17 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G {b.group.retryAction} ) : null} + {room.hostedStatus?.canReconnect && reconnectMemberId ? ( + + ) : null} {room.continuityIssue ? (
{room.continuityIssue}
diff --git a/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx b/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx index dde6d3b3ab7f3..23c64e07fdc9a 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx @@ -69,6 +69,7 @@ const eligibleProbe: HostedRoomProbe = { capability: { authorityId: 'install:studio', connectionId: 'host-a', + exactPeerGrantRevoke: false, kind: 'driver-capable', limits: { attachments: false, @@ -76,6 +77,7 @@ const eligibleProbe: HostedRoomProbe = { crossGatewayMembers: true }, persistentProcess: true, + routeGrantFingerprint: false, reason: null, roomLink: null }, @@ -167,11 +169,7 @@ describe('automatic Group Chat continuity', () => { const create = await renderSelectedGroup() expect(screen.getByText('New group chat')).toBeTruthy() - expect( - screen.getByText( - 'Choose 2–6 Bots.' - ) - ).toBeTruthy() + expect(screen.getByText('Choose 2–6 Bots.')).toBeTruthy() expect(screen.queryByRole('switch')).toBeNull() await waitFor(() => expect(create.disabled).toBe(false)) await act(async () => { @@ -276,9 +274,10 @@ describe('automatic Group Chat continuity', () => { member: expect.objectContaining({ connectionId: 'host-b', name: 'builder' }) }) ]) - expect( - mocks.saveBotMeta.mock.calls.map(([owner]) => (owner as { connectionId?: string }).connectionId) - ).toEqual(['host-a', 'host-b']) + expect(mocks.saveBotMeta.mock.calls.map(([owner]) => (owner as { connectionId?: string }).connectionId)).toEqual([ + 'host-a', + 'host-b' + ]) }) it('keeps Create disabled until the probe settles', async () => { diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.test.ts new file mode 100644 index 0000000000000..dfb17107c3f0f --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.test.ts @@ -0,0 +1,542 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { pluginSdkMock, scriptedStorage } from './group-test-utils' + +const mocks = vi.hoisted(() => ({ + host: {} as Record, + requestProfile: vi.fn() +})) + +vi.mock('@hermes/plugin-sdk', async () => pluginSdkMock(mocks.host)) + +const reconnectOperation = () => ({ + operationId: 'reconnect:room-1:builder:grant', + setupId: 'reconnect:room-1:builder', + kind: 'peer-reconnect' as const, + connectionId: 'peer', + profile: 'builder', + grant: 'private-grant', + grantSha256: 'a'.repeat(64), + expectedGrantSha256: 'c'.repeat(64), + roomId: 'room-1', + cancelId: null, + homeConnectionId: 'home', + homeProfile: 'default', + memberId: 'builder', + targetUrl: 'https://peer.example.test:19445/p/builder', + catalog: { + catalog_digest: 'digest:peer', + installation_id: 'install:peer' + } +}) + +async function loadCleanup() { + vi.resetModules() + + return import('./hosted-room-cleanup') +} + +function expireCleanupOwners(durable: Map) { + const cleanup = durable.get('hosted-room-cleanup-v1') as { + operations?: Array> + } + + durable.set('hosted-room-cleanup-v1', { + version: 1, + operations: (cleanup?.operations || []).map(operation => ({ + ...operation, + ownerLeaseUntil: 0 + })) + }) +} + +function testLockManager() { + const held = new Set() + const tails = new Map>() + + return { + request(name: string, options: { ifAvailable?: boolean }, callback: (lock: null | object) => Promise | T) { + if (options.ifAvailable) { + if (held.has(name)) { + return Promise.resolve(callback(null)) + } + + held.add(name) + return Promise.resolve(callback({})).finally(() => held.delete(name)) + } + + const previous = tails.get(name) || Promise.resolve() + const result = previous.then(async () => { + held.add(name) + try { + return await callback({}) + } finally { + held.delete(name) + } + }) + + tails.set( + name, + result.then( + () => undefined, + () => undefined + ) + ) + return result + } + } +} + +beforeEach(() => { + vi.clearAllMocks() + Object.assign(mocks.host, { + profileRoutes: async () => [ + { connectionId: 'home', mode: 'remote', profile: 'default', targetProfile: 'default' }, + { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' } + ], + requestProfile: mocks.requestProfile + }) +}) + +describe('hosted Group Chat cleanup journal', () => { + it('preserves overlapping writes from separate Desktop windows', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const originalLocks = Object.getOwnPropertyDescriptor(globalThis.navigator, 'locks') + + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: testLockManager() + }) + + try { + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + + const second = await loadCleanup() + + await second.startHostedRoomCleanup(storage) + + await Promise.all([ + first.addHostedRoomCleanup(reconnectOperation()), + second.addHostedRoomCleanup({ + ...reconnectOperation(), + operationId: 'reconnect:room-2:builder:grant', + setupId: 'reconnect:room-2:builder', + roomId: 'room-2' + }) + ]) + + expect((durable.get('hosted-room-cleanup-v1') as { operations: Array<{ roomId: string }> }).operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ roomId: 'room-1' }), + expect.objectContaining({ roomId: 'room-2' }) + ]) + ) + await second.dispatchHostedRoomCleanup() + expect(mocks.requestProfile).not.toHaveBeenCalled() + + const persisted = durable.get('hosted-room-cleanup-v1') as { + operations: Array<{ ownerLeaseUntil: number; roomId: string }> + } + durable.set('hosted-room-cleanup-v1', { + version: 1, + operations: persisted.operations.map(operation => + operation.roomId === 'room-1' ? { ...operation, ownerLeaseUntil: 0 } : operation + ) + }) + mocks.requestProfile.mockResolvedValue({ registered: true }) + await second.dispatchHostedRoomCleanup() + expect(mocks.requestProfile).not.toHaveBeenCalled() + + first.stopHostedRoomCleanup() + await second.dispatchHostedRoomCleanup() + + expect(mocks.requestProfile).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'home' }), + 'groups.peer.register', + expect.objectContaining({ room_id: 'room-1' }) + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: Array<{ roomId: string }> }).operations).toEqual([ + expect.objectContaining({ roomId: 'room-2' }) + ]) + second.stopHostedRoomCleanup() + } finally { + if (originalLocks) { + Object.defineProperty(globalThis.navigator, 'locks', originalLocks) + } else { + Reflect.deleteProperty(globalThis.navigator, 'locks') + } + } + }) + + it('rejects a cleanup write that production storage cannot read back', async () => { + const cleanup = await loadCleanup() + const storage = { + get: vi.fn(async () => null), + set: vi.fn(() => undefined) + } + + await cleanup.startHostedRoomCleanup(storage as never) + await expect(cleanup.addHostedRoomCleanup(reconnectOperation())).rejects.toThrow( + 'did not persist Group Chat cleanup' + ) + expect(cleanup.$hostedRoomCleanup.get().operations).toEqual([]) + }) + + it('journals an invitation response that arrives after runtime stop', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const cleanup = await loadCleanup() + + await cleanup.startHostedRoomCleanup(storage) + cleanup.stopHostedRoomCleanup() + await cleanup.addHostedRoomCleanup(reconnectOperation()) + await cleanup.armHostedRoomCleanup(reconnectOperation().setupId) + await cleanup.dispatchHostedRoomCleanup() + + expect(mocks.requestProfile).not.toHaveBeenCalled() + expect((durable.get('hosted-room-cleanup-v1') as { operations: Array<{ armed: boolean }> }).operations).toEqual([ + expect.objectContaining({ armed: true }) + ]) + }) + + it('replays a reconnect registration after the Desktop dies post-invite', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'needs_reauthorization', grant_sha256: 'c'.repeat(64) }] + } + } + } + + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'home' }), + 'groups.peer.register', + expect.objectContaining({ + expected_grant_sha256: 'c'.repeat(64), + grant: 'private-grant', + member_id: 'builder', + room_id: 'room-1' + }) + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('replays owner-tagged cleanup after a same-process stop and start', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const cleanup = await loadCleanup() + + await cleanup.startHostedRoomCleanup(storage) + await cleanup.addHostedRoomCleanup(reconnectOperation()) + cleanup.stopHostedRoomCleanup() + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + + await cleanup.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'home' }), + 'groups.peer.register', + expect.anything() + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('keeps a matching route pending until registration is positively revalidated', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + throw new Error('response lost') + } + + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'ready', grant_sha256: 'a'.repeat(64) }] + } + } + } + + if (method === 'groups.peer.revoke_exact') { + throw new Error('must not revoke a committed grant') + } + + throw new Error(`unexpected method: ${method}`) + }) + + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).not.toHaveBeenCalledWith( + expect.anything(), + 'groups.peer.revoke_exact', + expect.anything() + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toHaveLength(1) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'ready', grant_sha256: 'a'.repeat(64) }] + } + } + } + + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + await restarted.dispatchHostedRoomCleanup() + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('keeps a failed revocation durable and retries it later', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup({ + operationId: 'revoke:room-1:builder', + setupId: 'revoke:room-1:builder', + kind: 'peer-revoke', + connectionId: 'peer', + profile: 'builder', + grant: 'private-grant', + roomId: null, + cancelId: null, + homeConnectionId: null, + homeProfile: null, + memberId: null, + targetUrl: null, + catalog: null + }) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockRejectedValueOnce(new Error('peer offline')) + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toHaveLength(1) + + mocks.requestProfile.mockResolvedValueOnce({ revoked: true }) + await restarted.dispatchHostedRoomCleanup() + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('keeps reconnect cleanup pending when home state omits driver status', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + throw new Error('home unreachable') + } + + if (method === 'groups.state') { + return { room: { room_id: 'room-1' } } + } + + if (method === 'groups.peer.revoke_exact') { + throw new Error('must not revoke an ambiguous grant') + } + + throw new Error(`unexpected method: ${method}`) + }) + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).not.toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'peer' }), + 'groups.peer.revoke_exact', + expect.anything() + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toHaveLength(1) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + await restarted.dispatchHostedRoomCleanup() + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('keeps a committed but unavailable route pending after a lost reply', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + throw new Error('response lost') + } + + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'unavailable', grant_sha256: 'a'.repeat(64) }] + } + } + } + + if (method === 'groups.peer.revoke_exact') { + throw new Error('must not revoke a transiently unavailable route') + } + + throw new Error(`unexpected method: ${method}`) + }) + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).not.toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'peer' }), + 'groups.peer.revoke_exact', + expect.anything() + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toHaveLength(1) + }) + + it('exact-revokes only the losing grant when another Desktop wins', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + throw new Error('response lost') + } + + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'ready', grant_sha256: 'b'.repeat(64) }] + } + } + } + + if (method === 'groups.peer.revoke_exact') { + return { revoked: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).not.toHaveBeenCalledWith(expect.anything(), 'groups.peer.register', expect.anything()) + expect(mocks.requestProfile).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'peer' }), + 'groups.peer.revoke_exact', + { grant: 'private-grant', profile: 'builder' } + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('retries a failed revoke after a definitive reconnect rejection', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + let revokeFails = true + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + throw new Error('grant rejected') + } + + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'needs_reauthorization' }] + } + } + } + + if (method === 'groups.peer.revoke_exact') { + if (revokeFails) { + throw new Error('peer offline') + } + + return { revoked: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toHaveLength(1) + + revokeFails = false + await restarted.dispatchHostedRoomCleanup() + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.ts new file mode 100644 index 0000000000000..a943e70b7542e --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.ts @@ -0,0 +1,602 @@ +/** Durable compensation journal for hosted Group Chat setup and reconnect. */ + +import { atom, host } from '@hermes/plugin-sdk' +import type { PluginContext } from '@hermes/plugin-sdk' + +import { botsText } from './i18n' +import type { ProfileRoute } from './types' + +export const HOSTED_ROOM_CLEANUP_KEY = 'hosted-room-cleanup-v1' +const HOSTED_ROOM_CLEANUP_LIMIT = 64 +const HOSTED_ROOM_CLEANUP_LOCK = 'hermes-bots-hosted-room-cleanup' +const HOSTED_ROOM_OWNER_LOCK_PREFIX = 'hermes-bots-hosted-room-owner:' +const HOSTED_ROOM_OWNER_LEASE_MS = 60_000 + +export interface HostedRoomCleanupOperation { + armed: boolean + cancelId?: null | string + catalog?: null | Record + connectionId: string + expectedGrantSha256?: null | string + grant?: null | string + grantSha256?: null | string + homeConnectionId?: null | string + homeProfile?: null | string + kind: 'home-disband' | 'peer-reconnect' | 'peer-revoke' | 'peer-revoke-exact' + memberId?: null | string + operationId: string + ownerId: string + ownerLeaseUntil: number + profile?: null | string + roomId?: null | string + setupId: string + targetUrl?: null | string +} + +export interface HostedRoomCleanup { + operations: HostedRoomCleanupOperation[] + version: 1 +} + +export const $hostedRoomCleanup = atom({ version: 1, operations: [] }) + +let cleanupOwnerId = '' +let cleanupStorage: null | PluginContext['storage'] = null +let cleanupDispatching = false +let cleanupDisposed = true +let cleanupGeneration = 0 +let cleanupMutationTail: Promise = Promise.resolve() +let cleanupOwnerLockRelease: null | (() => void) = null + +interface CleanupLockManager { + request( + name: string, + options: { ifAvailable?: boolean; mode: 'exclusive' }, + callback: (lock: null | object) => Promise | T + ): Promise +} + +function newCleanupOwnerId() { + return globalThis.crypto?.randomUUID?.() || `desktop-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +function record(value: unknown): null | Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +async function routeForReference(connectionId: string, profile = 'default') { + if (typeof host.profileRoutes !== 'function') { + return null + } + + const routes = await host.profileRoutes() + + return ((Array.isArray(routes) ? routes : []).find(route => { + const routeProfile = String(route?.targetProfile || route?.profile || '') + + return String(route?.connectionId || '') === connectionId && routeProfile === profile + }) || null) as ProfileRoute | null +} + +async function request(route: ProfileRoute, method: string, params: Record) { + if (!route?.connectionId || typeof host.requestProfile !== 'function') { + throw new Error(botsText().group.hostRouteMissing) + } + + return host.requestProfile(route, method, params) as Promise +} + +export function normalizeHostedRoomCleanup(value: unknown): HostedRoomCleanup { + const candidate = record(value) + const operations: HostedRoomCleanupOperation[] = [] + + for (const raw of Array.isArray(candidate?.operations) ? candidate.operations : []) { + const operation = record(raw) + const operationId = String(operation?.operationId || '') + const setupId = String(operation?.setupId || '') + const kind = String(operation?.kind || '') + const connectionId = String(operation?.connectionId || '') + + if ( + !operationId || + !setupId || + !connectionId || + !['home-disband', 'peer-reconnect', 'peer-revoke', 'peer-revoke-exact'].includes(kind) + ) { + continue + } + + if (kind === 'home-disband' && !String(operation?.roomId || '')) { + continue + } + + if ( + ['peer-revoke', 'peer-revoke-exact'].includes(kind) && + (!String(operation?.grant || '') || !String(operation?.profile || '')) + ) { + continue + } + + if ( + kind === 'peer-reconnect' && + (!String(operation?.grant || '') || + !/^[0-9a-f]{64}$/.test(String(operation?.grantSha256 || '')) || + !String(operation?.profile || '') || + !String(operation?.homeConnectionId || '') || + !String(operation?.homeProfile || '') || + !String(operation?.roomId || '') || + !String(operation?.memberId || '') || + !String(operation?.targetUrl || '') || + !record(operation?.catalog)) + ) { + continue + } + + const ownerId = String(operation?.ownerId || '') + const ownerLeaseUntil = Number(operation?.ownerLeaseUntil || 0) + + operations.push({ + armed: operation?.armed === true || !ownerId, + operationId, + setupId, + kind: kind as HostedRoomCleanupOperation['kind'], + connectionId, + ownerId, + ownerLeaseUntil: Number.isFinite(ownerLeaseUntil) && ownerLeaseUntil > 0 ? ownerLeaseUntil : 0, + roomId: ['home-disband', 'peer-reconnect'].includes(kind) ? String(operation?.roomId || '') : null, + cancelId: + kind === 'home-disband' ? String(operation?.cancelId || `rollback-${String(operation?.roomId || '')}`) : null, + profile: kind === 'home-disband' ? null : String(operation?.profile || ''), + grant: kind === 'home-disband' ? null : String(operation?.grant || ''), + grantSha256: kind === 'peer-reconnect' ? String(operation?.grantSha256 || '') : null, + expectedGrantSha256: + kind === 'peer-reconnect' && /^[0-9a-f]{64}$/.test(String(operation?.expectedGrantSha256 || '')) + ? String(operation?.expectedGrantSha256) + : null, + homeConnectionId: kind === 'peer-reconnect' ? String(operation?.homeConnectionId || '') : null, + homeProfile: kind === 'peer-reconnect' ? String(operation?.homeProfile || '') : null, + memberId: kind === 'peer-reconnect' ? String(operation?.memberId || '') : null, + targetUrl: kind === 'peer-reconnect' ? String(operation?.targetUrl || '') : null, + catalog: kind === 'peer-reconnect' ? record(operation?.catalog) : null + }) + } + + return { + version: 1, + operations: operations.slice(-HOSTED_ROOM_CLEANUP_LIMIT) + } +} + +function processCleanupLock(callback: () => Promise) { + const result = cleanupMutationTail.then(callback, callback) + + cleanupMutationTail = result.then( + () => undefined, + () => undefined + ) + + return result +} + +function cleanupLockManager() { + return (globalThis.navigator as (Navigator & { locks?: CleanupLockManager }) | undefined)?.locks +} + +async function withCleanupLock(callback: () => Promise) { + const locks = cleanupLockManager() + + return locks?.request + ? locks.request(HOSTED_ROOM_CLEANUP_LOCK, { mode: 'exclusive' }, callback) + : processCleanupLock(callback) +} + +async function holdCleanupOwnerLock(ownerId: string) { + cleanupOwnerLockRelease?.() + cleanupOwnerLockRelease = null + const locks = cleanupLockManager() + + if (!locks?.request) { + return + } + + let entered: () => void = () => undefined + let release: () => void = () => undefined + const acquired = new Promise(resolve => { + entered = resolve + }) + const held = new Promise(resolve => { + release = resolve + }) + + cleanupOwnerLockRelease = release + void locks + .request(`${HOSTED_ROOM_OWNER_LOCK_PREFIX}${ownerId}`, { mode: 'exclusive' }, async () => { + entered() + await held + }) + .catch(() => entered()) + await acquired +} + +async function cleanupOwnerIsLive(operation: HostedRoomCleanupOperation) { + if (!operation.ownerId) { + return false + } + + if (operation.ownerId === cleanupOwnerId) { + return !operation.armed + } + + const locks = cleanupLockManager() + + if (!locks?.request) { + return operation.ownerLeaseUntil > Date.now() + } + + try { + let live = true + + await locks.request( + `${HOSTED_ROOM_OWNER_LOCK_PREFIX}${operation.ownerId}`, + { ifAvailable: true, mode: 'exclusive' }, + lock => { + live = lock === null + } + ) + + return live + } catch { + return operation.ownerLeaseUntil > Date.now() + } +} + +async function readPersistedCleanup() { + if (!cleanupStorage?.get) { + throw new Error('Desktop storage is unavailable, so Group Chat setup cannot be secured.') + } + + return normalizeHostedRoomCleanup(await cleanupStorage.get(HOSTED_ROOM_CLEANUP_KEY, null)) +} + +async function replaceCleanup(previous: HostedRoomCleanup, next: HostedRoomCleanup) { + if (!cleanupStorage?.set || !cleanupStorage?.get) { + throw new Error('Desktop storage is unavailable, so Group Chat setup cannot be secured.') + } + + $hostedRoomCleanup.set(next) + + try { + await cleanupStorage.set(HOSTED_ROOM_CLEANUP_KEY, next) + const persisted = normalizeHostedRoomCleanup(await cleanupStorage.get(HOSTED_ROOM_CLEANUP_KEY, null)) + + if (JSON.stringify(persisted) !== JSON.stringify(next)) { + throw new Error('Desktop storage did not persist Group Chat cleanup.') + } + } catch (error) { + $hostedRoomCleanup.set(previous) + throw error + } +} + +async function mutateCleanup(update: (current: HostedRoomCleanup) => HostedRoomCleanup) { + return withCleanupLock(async () => { + const current = await readPersistedCleanup() + const next = normalizeHostedRoomCleanup(update(current)) + + if (JSON.stringify(current) === JSON.stringify(next)) { + $hostedRoomCleanup.set(current) + return current + } + + await replaceCleanup(current, next) + + return next + }) +} + +export async function addHostedRoomCleanup( + operation: Omit +) { + await mutateCleanup(current => { + const next = normalizeHostedRoomCleanup({ + version: 1, + operations: [ + ...current.operations.filter(entry => entry.operationId !== operation.operationId), + { + ...operation, + armed: false, + ownerId: cleanupOwnerId, + ownerLeaseUntil: Date.now() + HOSTED_ROOM_OWNER_LEASE_MS + } + ] + }) + + if (next.operations.length >= HOSTED_ROOM_CLEANUP_LIMIT && current.operations.length >= HOSTED_ROOM_CLEANUP_LIMIT) { + throw new Error('Group Chat cleanup is pending. Reconnect the affected devices before creating another.') + } + + return next + }) +} + +export async function releaseHostedRoomCleanup(setupId: string) { + await mutateCleanup(current => ({ + version: 1, + operations: current.operations.filter(operation => operation.setupId !== setupId) + })) +} + +export async function armHostedRoomCleanup(setupId: string) { + await mutateCleanup(current => ({ + version: 1, + operations: current.operations.map(operation => + operation.setupId === setupId + ? { + ...operation, + armed: true, + ownerId: '', + ownerLeaseUntil: 0 + } + : operation + ) + })) +} + +export function hostedRoomCleanupPending(setupId: string) { + return normalizeHostedRoomCleanup($hostedRoomCleanup.get()).operations.some( + operation => operation.setupId === setupId + ) +} + +function homeDisbandAlreadySettled(operation: HostedRoomCleanupOperation, error: unknown) { + const candidate = record(error) + const inner = record(candidate?.error) + const code = Number(candidate?.code ?? inner?.code) + const message = String(candidate?.message || inner?.message || error || '') + + return operation.kind === 'home-disband' && code === 4113 && /hosted room not found|already disbanded/i.test(message) +} + +async function peerRouteStatus(operation: HostedRoomCleanupOperation, homeRoute: ProfileRoute) { + const state = record( + await request>(homeRoute, 'groups.state', { + room_id: operation.roomId + }) + ) + const driver = record(state?.driver_status) + if (!driver || !Array.isArray(driver.peer_routes)) { + return 'unknown' as const + } + const route = driver.peer_routes + .map(record) + .find(candidate => String(candidate?.member_id || '') === String(operation.memberId || '')) + + const status = String(route?.status || '') + const grantSha256 = String(route?.grant_sha256 || '') + const sameGrant = grantSha256 && grantSha256 === String(operation.grantSha256 || '') + const expectedGrant = grantSha256 && grantSha256 === String(operation.expectedGrantSha256 || '') + + if (status === 'needs_reauthorization' && sameGrant) { + return 'nonready' as const + } + + if (sameGrant) { + return 'matching' as const + } + + if (expectedGrant || (!grantSha256 && !operation.expectedGrantSha256)) { + return 'expected' as const + } + + if (grantSha256) { + return 'conflict' as const + } + + if (status === 'needs_reauthorization') { + return 'nonready' as const + } + + return 'unknown' as const +} + +async function settlePeerReconnect(operation: HostedRoomCleanupOperation) { + const homeRoute = await routeForReference( + String(operation.homeConnectionId || ''), + String(operation.homeProfile || 'default') + ) + + if (!homeRoute) { + return 'pending' as const + } + + try { + if (['conflict', 'nonready'].includes(await peerRouteStatus(operation, homeRoute))) { + return 'revoke' as const + } + } catch { + /* registration remains the only safe settlement proof */ + } + + try { + await request(homeRoute, 'groups.peer.register', { + room_id: operation.roomId, + member_id: operation.memberId, + target_url: operation.targetUrl, + target_profile: operation.profile, + grant: operation.grant, + catalog: operation.catalog, + expected_grant_sha256: operation.expectedGrantSha256 || '' + }) + + return 'settled' as const + } catch { + try { + return ['conflict', 'nonready'].includes(await peerRouteStatus(operation, homeRoute)) + ? ('revoke' as const) + : ('pending' as const) + } catch { + return 'pending' as const + } + } +} + +async function runCleanup(operation: HostedRoomCleanupOperation) { + if (operation.kind === 'peer-reconnect') { + const outcome = await settlePeerReconnect(operation) + + if (outcome === 'settled') { + return true + } + + if (outcome === 'pending') { + return false + } + } + + const profile = operation.kind === 'home-disband' ? 'default' : String(operation.profile || '') + const route = await routeForReference(operation.connectionId, profile) + + if (!route) { + return false + } + + try { + if (operation.kind === 'home-disband') { + await request(route, 'groups.disband', { + room_id: operation.roomId, + cancel_id: operation.cancelId + }) + } else { + await request(route, operation.kind === 'peer-revoke' ? 'groups.peer.revoke' : 'groups.peer.revoke_exact', { + grant: operation.grant, + profile: operation.profile + }) + } + + return true + } catch (error) { + return homeDisbandAlreadySettled(operation, error) + } +} + +export async function dispatchHostedRoomCleanup() { + if (cleanupDispatching || cleanupDisposed) { + return + } + + cleanupDispatching = true + + try { + const snapshot = await mutateCleanup(current => ({ + version: 1, + operations: current.operations.map(operation => + operation.ownerId === cleanupOwnerId && !operation.armed + ? { + ...operation, + ownerLeaseUntil: Date.now() + HOSTED_ROOM_OWNER_LEASE_MS + } + : operation + ) + })) + + for (const operation of snapshot.operations) { + if (await cleanupOwnerIsLive(operation)) { + continue + } + + let claimed: HostedRoomCleanupOperation | null = null + + await mutateCleanup(current => ({ + version: 1, + operations: current.operations.map(entry => { + if (JSON.stringify(entry) !== JSON.stringify(operation)) { + return entry + } + + claimed = { + ...entry, + armed: true, + ownerId: cleanupOwnerId, + ownerLeaseUntil: Date.now() + HOSTED_ROOM_OWNER_LEASE_MS + } + + return claimed + }) + })) + + if (!claimed || !(await runCleanup(claimed))) { + continue + } + + await mutateCleanup(latest => ({ + version: 1, + operations: latest.operations.filter( + entry => entry.operationId !== claimed?.operationId || JSON.stringify(entry) !== JSON.stringify(claimed) + ) + })) + } + } finally { + cleanupDispatching = false + } +} + +export async function startHostedRoomCleanup(storage: PluginContext['storage']) { + const generation = ++cleanupGeneration + const previousOwnerId = cleanupOwnerId + cleanupOwnerId = newCleanupOwnerId() + cleanupStorage = storage + cleanupDisposed = false + await holdCleanupOwnerLock(cleanupOwnerId) + + await withCleanupLock(async () => { + let persisted: unknown = null + + try { + persisted = await storage?.get?.(HOSTED_ROOM_CLEANUP_KEY, null) + } catch { + /* empty cleanup is the safe fallback */ + } + + if (!cleanupDisposed && generation === cleanupGeneration) { + const current = normalizeHostedRoomCleanup(persisted) + const next = normalizeHostedRoomCleanup({ + version: 1, + operations: current.operations.map(operation => + previousOwnerId && operation.ownerId === previousOwnerId + ? { + ...operation, + armed: true, + ownerId: '', + ownerLeaseUntil: 0 + } + : operation + ) + }) + + await replaceCleanup(current, next) + } + }) + + if (cleanupDisposed || generation !== cleanupGeneration) { + return + } + + await dispatchHostedRoomCleanup().catch(() => undefined) +} + +export function stopHostedRoomCleanup() { + cleanupGeneration += 1 + cleanupDisposed = true + cleanupOwnerLockRelease?.() + cleanupOwnerLockRelease = null +} + +export function resetHostedRoomCleanupForTests() { + stopHostedRoomCleanup() + cleanupDispatching = false + cleanupOwnerId = '' + cleanupStorage = null + $hostedRoomCleanup.set({ version: 1, operations: [] }) +} diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts index e3403a5a68d88..1b9f36cd2264d 100644 --- a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts @@ -58,6 +58,8 @@ describe('hosted Group Chat capability negotiation', () => { driver: true, persistent_process: true, authority_gateway_id: 'install:home', + features: ['peer_route_grant_fingerprint'], + methods: ['groups.peer.revoke_exact'], max_log_limit: 250 }, { @@ -74,10 +76,26 @@ describe('hosted Group Chat capability negotiation', () => { expect(capable).toMatchObject({ kind: 'driver-capable', authorityId: 'install:home', + exactPeerGrantRevoke: true, persistentProcess: true, + routeGrantFingerprint: true, maxLogLimit: 250 }) expect(isHostedRoomContinuityEligible(capable)).toBe(true) + expect( + classifyHostedRoomCapability({ + driver: true, + persistent_process: true, + authority_gateway_id: 'install:older' + }).exactPeerGrantRevoke + ).toBe(false) + expect( + classifyHostedRoomCapability({ + driver: true, + persistent_process: true, + authority_gateway_id: 'install:older' + }).routeGrantFingerprint + ).toBe(false) expect( isHostedRoomContinuityEligible({ driver: true, diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts index b6027c3e21b95..9a3bdbbd82af1 100644 --- a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts @@ -36,10 +36,12 @@ export type HostedRoomCapabilityKind = 'driver-capable' | 'transient-failure' | export interface HostedRoomCapability { authorityId: null | string connectionId: null | string + exactPeerGrantRevoke: boolean kind: HostedRoomCapabilityKind limits: typeof HOSTED_ROOM_CLIENT_LIMITATIONS maxLogLimit?: number persistentProcess: boolean | null + routeGrantFingerprint: boolean reason: null | string roomLink: null | RoomLinkCapability } @@ -305,8 +307,10 @@ export function classifyHostedRoomCapability( kind: unsupported ? 'unsupported' : 'transient-failure', reason: unsupported ? 'old-gateway' : 'probe-failed', connectionId: localConnectionId, + exactPeerGrantRevoke: false, authorityId: null, persistentProcess: null, + routeGrantFingerprint: false, roomLink: null, limits: HOSTED_ROOM_CLIENT_LIMITATIONS } @@ -319,8 +323,10 @@ export function classifyHostedRoomCapability( kind: 'transient-failure', reason: 'invalid-response', connectionId: localConnectionId, + exactPeerGrantRevoke: false, authorityId: null, persistentProcess: null, + routeGrantFingerprint: false, roomLink: null, limits: HOSTED_ROOM_CLIENT_LIMITATIONS } @@ -331,8 +337,10 @@ export function classifyHostedRoomCapability( kind: 'unsupported', reason: capabilities.driver === false ? 'driver-disabled' : 'incomplete-contract', connectionId: localConnectionId, + exactPeerGrantRevoke: false, authorityId: null, persistentProcess: capabilities.persistent_process === true, + routeGrantFingerprint: false, roomLink: roomLinkCapability(capabilities.room_link), limits: HOSTED_ROOM_CLIENT_LIMITATIONS } @@ -345,8 +353,10 @@ export function classifyHostedRoomCapability( kind: 'unsupported', reason: 'incomplete-contract', connectionId: localConnectionId, + exactPeerGrantRevoke: false, authorityId: null, persistentProcess: capabilities.persistent_process === true, + routeGrantFingerprint: false, roomLink: roomLinkCapability(capabilities.room_link), limits: HOSTED_ROOM_CLIENT_LIMITATIONS } @@ -356,8 +366,12 @@ export function classifyHostedRoomCapability( kind: 'driver-capable', reason: null, connectionId: localConnectionId, + exactPeerGrantRevoke: + Array.isArray(capabilities.methods) && capabilities.methods.includes('groups.peer.revoke_exact'), authorityId, persistentProcess: capabilities.persistent_process === true, + routeGrantFingerprint: + Array.isArray(capabilities.features) && capabilities.features.includes('peer_route_grant_fingerprint'), roomLink: roomLinkCapability(capabilities.room_link), maxLogLimit: positiveInteger(capabilities.max_log_limit, 100) || 100, limits: HOSTED_ROOM_CLIENT_LIMITATIONS diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.test.ts new file mode 100644 index 0000000000000..999b1b1c64d5f --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.test.ts @@ -0,0 +1,631 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { pluginSdkMock } from './group-test-utils' + +const EXPECTED_GRANT_SHA256 = 'c'.repeat(64) + +const mocks = vi.hoisted(() => { + const capabilities = { + value: {} as Record, + get() { + return this.value + }, + set(value: Record) { + this.value = value + } + } + + return { + addCleanup: vi.fn(async () => undefined), + armCleanup: vi.fn(async () => undefined), + capabilities, + dispatchCleanup: vi.fn(async () => undefined), + host: {} as Record, + lifecycle: { value: 1 }, + refresh: vi.fn(async () => undefined), + invalidate: vi.fn(), + requestForBot: vi.fn(), + requestHosted: vi.fn(), + releaseCleanup: vi.fn(async () => undefined) + } +}) + +vi.mock('@hermes/plugin-sdk', async () => pluginSdkMock(mocks.host)) + +vi.mock('./hosted-room-runtime', () => ({ + $hostedRoomCapabilities: mocks.capabilities, + invalidateHostedRoomPoll: mocks.invalidate, + hostedRoomLifecycleIsCurrent: (token: number) => token === mocks.lifecycle.value, + hostedRoomLifecycleToken: () => mocks.lifecycle.value, + refreshHostedRooms: mocks.refresh, + requestHostedConnection: mocks.requestHosted +})) + +vi.mock('./hosted-room-cleanup', () => ({ + addHostedRoomCleanup: mocks.addCleanup, + armHostedRoomCleanup: mocks.armCleanup, + dispatchHostedRoomCleanup: mocks.dispatchCleanup, + releaseHostedRoomCleanup: mocks.releaseCleanup +})) + +vi.mock('./routing', () => ({ + requestForBot: mocks.requestForBot +})) + +beforeEach(() => { + vi.clearAllMocks() + mocks.lifecycle.value = 1 + mocks.capabilities.value = { + home: { + authorityId: 'install:home', + routeGrantFingerprint: true + } + } + Object.assign(mocks.host, { + profileRoutes: async () => [ + { connectionId: 'home', mode: 'remote', profile: 'default', targetProfile: 'default' }, + { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' } + ] + }) +}) + +describe('hosted Group Chat peer reauthorization', () => { + it('issues a fresh peer grant and registers it on the existing authority', async () => { + const { $groupChats } = await import('./group-chat') + const { reconnectHostedGroupChatPeer } = await import('./hosted-room-reauthorization') + + $groupChats.set({ + Release: { + continuityMode: 'distributed', + hosted: 'install:home', + hostedConnectionId: 'home', + hostedEpoch: 1, + log: [], + members: [ + { + connectionId: 'peer', + handle: 'builder', + name: 'builder', + route: { + connectionId: 'peer', + mode: 'remote', + profile: 'builder', + targetProfile: 'builder' + }, + sourceScoped: true, + targetProfile: 'builder' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + + mocks.requestHosted.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [ + { + grant_sha256: EXPECTED_GRANT_SHA256, + member_id: 'member-builder', + status: 'needs_reauthorization' + } + ] + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: [ + { + display_name: 'Builder', + handle: 'builder', + member_id: 'member-builder', + profile: 'builder', + target: { + installation_id: 'install:peer', + kind: 'peer', + peer_id: 'install:peer' + } + } + ], + room_id: 'room-1' + } + } + } + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:peer', + methods: ['groups.peer.revoke_exact'], + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://peer.example.test:19445' }, + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected hosted method: ${method}`) + }) + mocks.requestForBot.mockResolvedValue({ + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + }, + grant: 'private-grant', + target_profile: 'builder' + }) + + await reconnectHostedGroupChatPeer('Release', 'member-builder') + + expect(mocks.requestForBot).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'peer', targetProfile: 'builder' }), + 'groups.peer.invite', + expect.objectContaining({ + authority_epoch: 1, + authority_gateway_id: 'install:home', + member_id: 'member-builder', + room_id: 'room-1' + }) + ) + expect(mocks.requestHosted).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'home' }), + 'groups.peer.register', + expect.objectContaining({ + grant: 'private-grant', + member_id: 'member-builder', + room_id: 'room-1', + target_profile: 'builder', + target_url: 'https://peer.example.test:19445/p/builder' + }) + ) + expect(mocks.refresh).toHaveBeenCalledOnce() + expect(mocks.invalidate).toHaveBeenCalledWith('room-1') + expect(mocks.addCleanup).toHaveBeenCalledTimes(2) + expect(mocks.releaseCleanup).toHaveBeenCalledOnce() + }) + + it('revokes the fresh peer grant when home registration fails', async () => { + const { $groupChats } = await import('./group-chat') + const { reconnectHostedGroupChatPeer } = await import('./hosted-room-reauthorization') + + $groupChats.set({ + Release: { + continuityMode: 'distributed', + hosted: 'install:home', + hostedConnectionId: 'home', + hostedEpoch: 1, + log: [], + members: [ + { + connectionId: 'peer', + handle: 'builder', + name: 'builder', + route: { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' }, + sourceScoped: true, + targetProfile: 'builder' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + let stateCalls = 0 + mocks.requestHosted.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + stateCalls += 1 + return { + driver_status: { + peer_routes: [ + { + grant_sha256: EXPECTED_GRANT_SHA256, + member_id: 'member-builder', + status: 'needs_reauthorization' + } + ] + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: [ + { + handle: 'builder', + member_id: 'member-builder', + profile: 'builder', + target: { installation_id: 'install:peer', kind: 'peer' } + } + ] + } + } + } + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:peer', + methods: ['groups.peer.revoke_exact'], + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://peer.example.test:19445' }, + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.peer.register') { + throw new Error('register failed') + } + + throw new Error(`unexpected hosted method: ${method}`) + }) + mocks.requestForBot.mockResolvedValue({ + catalog: { + catalog_digest: 'digest:peer', + installation_id: 'install:peer' + }, + grant: 'private-grant', + target_profile: 'builder' + }) + await expect(reconnectHostedGroupChatPeer('Release', 'member-builder')).rejects.toThrow('register failed') + expect(mocks.armCleanup).toHaveBeenCalledOnce() + expect(mocks.dispatchCleanup).toHaveBeenCalledOnce() + expect(mocks.refresh).not.toHaveBeenCalled() + expect(mocks.invalidate).not.toHaveBeenCalled() + }) + + it('journals the fresh grant before rejecting an invalid invitation', async () => { + const { $groupChats } = await import('./group-chat') + const { reconnectHostedGroupChatPeer } = await import('./hosted-room-reauthorization') + + $groupChats.set({ + Release: { + continuityMode: 'distributed', + hosted: 'install:home', + hostedConnectionId: 'home', + hostedEpoch: 1, + log: [], + members: [ + { + connectionId: 'peer', + handle: 'builder', + name: 'builder', + route: { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' }, + sourceScoped: true, + targetProfile: 'builder' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + mocks.requestHosted.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [ + { + grant_sha256: EXPECTED_GRANT_SHA256, + member_id: 'member-builder', + status: 'needs_reauthorization' + } + ] + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: [ + { + handle: 'builder', + member_id: 'member-builder', + profile: 'builder', + target: { installation_id: 'install:peer', kind: 'peer' } + } + ] + } + } + } + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:peer', + methods: ['groups.peer.revoke_exact'], + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://peer.example.test:19445' }, + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + throw new Error(`unexpected hosted method: ${method}`) + }) + mocks.requestForBot.mockResolvedValue({ + catalog: { + installation_id: 'install:peer' + }, + grant: 'private-grant', + target_profile: 'builder' + }) + + await expect(reconnectHostedGroupChatPeer('Release', 'member-builder')).rejects.toThrow( + 'could not prepare a secure connection' + ) + expect(mocks.addCleanup).toHaveBeenCalledOnce() + expect(mocks.addCleanup).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: 'peer', + grant: 'private-grant', + kind: 'peer-revoke-exact', + profile: 'builder' + }) + ) + expect(mocks.armCleanup).toHaveBeenCalledOnce() + expect(mocks.dispatchCleanup).toHaveBeenCalledOnce() + expect(mocks.requestHosted).not.toHaveBeenCalledWith(expect.anything(), 'groups.peer.register', expect.anything()) + }) + + it('does not report success when a registration reply is lost', async () => { + const { $groupChats } = await import('./group-chat') + const { reconnectHostedGroupChatPeer } = await import('./hosted-room-reauthorization') + + $groupChats.set({ + Release: { + continuityMode: 'distributed', + hosted: 'install:home', + hostedConnectionId: 'home', + hostedEpoch: 1, + log: [], + members: [ + { + connectionId: 'peer', + handle: 'builder', + name: 'builder', + route: { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' }, + sourceScoped: true, + targetProfile: 'builder' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + let stateCalls = 0 + mocks.requestHosted.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + stateCalls += 1 + return { + driver_status: + stateCalls > 1 + ? { + peer_routes: [ + { + member_id: 'member-builder', + status: 'ready', + grant_sha256: '73238410238d13fffbccfb5ba0142555042d7153fd8196fcf6bba1c1ead06c5a' + } + ] + } + : { + peer_routes: [ + { + grant_sha256: EXPECTED_GRANT_SHA256, + member_id: 'member-builder', + status: 'needs_reauthorization' + } + ] + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: [ + { + handle: 'builder', + member_id: 'member-builder', + profile: 'builder', + target: { installation_id: 'install:peer', kind: 'peer' } + } + ] + } + } + } + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:peer', + methods: ['groups.peer.revoke_exact'], + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://peer.example.test:19445' }, + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.peer.register') { + throw new Error('response lost') + } + + throw new Error(`unexpected hosted method: ${method}`) + }) + mocks.requestForBot.mockResolvedValue({ + catalog: { + catalog_digest: 'digest:peer', + installation_id: 'install:peer' + }, + grant: 'private-grant', + target_profile: 'builder' + }) + + await expect(reconnectHostedGroupChatPeer('Release', 'member-builder')).rejects.toThrow('response lost') + expect(mocks.armCleanup).toHaveBeenCalledOnce() + expect(mocks.dispatchCleanup).toHaveBeenCalledOnce() + expect(mocks.invalidate).not.toHaveBeenCalled() + expect(mocks.refresh).not.toHaveBeenCalled() + }) + + it('coalesces clicks within one lifecycle but lets a restarted runtime retry independently', async () => { + const { $groupChats } = await import('./group-chat') + const { reconnectHostedGroupChatPeer } = await import('./hosted-room-reauthorization') + + $groupChats.set({ + Release: { + continuityMode: 'distributed', + hosted: 'install:home', + hostedConnectionId: 'home', + hostedEpoch: 1, + log: [], + members: [ + { + connectionId: 'peer', + handle: 'builder', + name: 'builder', + route: { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' }, + sourceScoped: true, + targetProfile: 'builder' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + mocks.requestHosted.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [ + { + grant_sha256: EXPECTED_GRANT_SHA256, + member_id: 'member-builder', + status: 'needs_reauthorization' + } + ] + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: [ + { + handle: 'builder', + member_id: 'member-builder', + profile: 'builder', + target: { installation_id: 'install:peer', kind: 'peer' } + } + ] + } + } + } + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:peer', + methods: ['groups.peer.revoke_exact'], + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://peer.example.test:19445' }, + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected hosted method: ${method}`) + }) + const releaseInvites: Array<(grant: string) => void> = [] + mocks.requestForBot.mockImplementation( + () => + new Promise(resolve => { + releaseInvites.push(grant => + resolve({ + catalog: { + catalog_digest: 'digest:peer', + installation_id: 'install:peer' + }, + grant, + target_profile: 'builder' + }) + ) + }) + ) + + const first = reconnectHostedGroupChatPeer('Release', 'member-builder') + const second = reconnectHostedGroupChatPeer('Release', 'member-builder') + + expect(first).toBe(second) + await vi.waitFor(() => expect(mocks.requestForBot).toHaveBeenCalledOnce()) + mocks.lifecycle.value = 2 + const third = reconnectHostedGroupChatPeer('Release', 'member-builder') + + expect(third).not.toBe(first) + await vi.waitFor(() => expect(mocks.requestForBot).toHaveBeenCalledTimes(2)) + releaseInvites[0]('old-private-grant') + releaseInvites[1]('new-private-grant') + + await expect(first).rejects.toThrow('connections changed') + await expect(second).rejects.toThrow('connections changed') + await expect(third).resolves.toBeUndefined() + expect(mocks.armCleanup).toHaveBeenCalled() + expect(mocks.dispatchCleanup).toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.ts new file mode 100644 index 0000000000000..3aebe4409d803 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.ts @@ -0,0 +1,305 @@ +/** Explicitly renew one peer route after its policy or capability catalog changed. */ + +import { host } from '@hermes/plugin-sdk' + +import { $groupChats, groupChatHostedGateway } from './group-chat' +import { + addHostedRoomCleanup, + armHostedRoomCleanup, + dispatchHostedRoomCleanup, + releaseHostedRoomCleanup +} from './hosted-room-cleanup' +import { + classifyHostedRoomCapability, + isHostedRoomContinuityEligible, + profileScopedRoomLinkEndpoint +} from './hosted-room-client' +import { + $hostedRoomCapabilities, + hostedRoomLifecycleIsCurrent, + hostedRoomLifecycleToken, + invalidateHostedRoomPoll, + refreshHostedRooms, + requestHostedConnection +} from './hosted-room-runtime' +import { requestForBot } from './routing' +import type { GroupMember, ProfileRoute } from './types' + +const reconnectingPeers = new Map }>() + +function record(value: unknown): null | Record { + return value !== null && typeof value === 'object' ? (value as Record) : null +} + +async function sha256(value: string) { + const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)) + + return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('') +} + +async function routes() { + if (typeof host.profileRoutes !== 'function') { + return [] as ProfileRoute[] + } + + const value = await host.profileRoutes() + + return (Array.isArray(value) ? value : []) as ProfileRoute[] +} + +function matchingLocalMember(members: GroupMember[], serverMember: Record) { + const profile = String(serverMember.profile || serverMember.member_id || '') + const handle = String(serverMember.handle || '') + + return members.find( + member => + String(member.targetProfile || member.name || '') === profile && + (!handle || String(member.handle || member.name || '') === handle) + ) +} + +async function reconnectPeer(group: string, memberId: string, lifecycle: number) { + const assertCurrent = () => { + if (!hostedRoomLifecycleIsCurrent(lifecycle)) { + throw new Error('Group Chat connections changed. Try Reconnect again.') + } + } + const room = $groupChats.get()[group] + const roomId = String(room?.roomId || '') + const homeAuthority = groupChatHostedGateway(room) + const allRoutes = await routes() + assertCurrent() + const homeRoute = allRoutes.find(route => String(route.connectionId || '') === String(room?.hostedConnectionId || '')) + + if (!room || !roomId || !homeAuthority || !homeRoute) { + throw new Error('Open the gateway that owns this Group Chat, then try again.') + } + + const homeCapability = $hostedRoomCapabilities.get()[String(homeRoute.connectionId || '')] + + if (!homeCapability?.routeGrantFingerprint || homeCapability.authorityId !== homeAuthority) { + throw new Error('Update the gateway that owns this Group Chat, then try again.') + } + + const state = record( + await requestHostedConnection>(homeRoute, 'groups.state', { + room_id: roomId + }) + ) + assertCurrent() + const serverRoom = record(state?.room) + const driver = record(state?.driver_status) + const authorityId = String(serverRoom?.authority_gateway_id || '') + const authorityEpoch = Number(serverRoom?.authority_epoch || 0) + const serverMember = (Array.isArray(serverRoom?.members) ? serverRoom.members : []) + .map(record) + .find(member => String(member?.member_id || '') === memberId) + const target = record(serverMember?.target) + const targetAuthority = String(target?.installation_id || target?.peer_id || '') + const localMember = serverMember ? matchingLocalMember(room.members || [], serverMember) : null + const peerConnectionId = String(localMember?.route?.connectionId || localMember?.connectionId || '') + const profile = String(serverMember?.profile || serverMember?.member_id || 'default') + const currentPeerRoute = (Array.isArray(driver?.peer_routes) ? driver.peer_routes : []) + .map(record) + .find(route => String(route?.member_id || '') === memberId) + const expectedGrantSha256 = String(currentPeerRoute?.grant_sha256 || '') + const peerRoute = allRoutes.find( + route => + String(route.connectionId || '') === peerConnectionId && + String(route.targetProfile || route.profile || '') === profile + ) + + if ( + !serverMember || + target?.kind !== 'peer' || + !authorityId || + authorityId !== homeAuthority || + !Number.isSafeInteger(authorityEpoch) || + authorityEpoch < 1 || + !localMember || + !targetAuthority || + !/^[0-9a-f]{64}$/.test(expectedGrantSha256) || + !peerRoute + ) { + throw new Error('Reconnect the Bot gateway in Sessions, then try again.') + } + + const peerCapability = classifyHostedRoomCapability(await requestHostedConnection(peerRoute, 'groups.capabilities'), { + connectionId: peerConnectionId + }) + assertCurrent() + + $hostedRoomCapabilities.set({ + ...$hostedRoomCapabilities.get(), + [peerConnectionId]: peerCapability + }) + + if ( + !isHostedRoomContinuityEligible(peerCapability) || + !peerCapability.exactPeerGrantRevoke || + peerCapability.authorityId !== targetAuthority + ) { + throw new Error('That Bot gateway cannot reconnect to this Group Chat yet.') + } + + const invitation = record( + await requestForBot(localMember, 'groups.peer.invite', { + room_id: roomId, + home_install_id: authorityId, + authority_gateway_id: authorityId, + authority_epoch: authorityEpoch, + member_id: memberId, + profile + }) + ) + const catalog = record(invitation?.catalog) + const grant = String(invitation?.grant || '') + const targetProfile = String(invitation?.target_profile || profile) + const targetUrl = profileScopedRoomLinkEndpoint(peerCapability.roomLink?.endpoint, invitation?.target_profile) + const setupId = `reconnect:${roomId}:${memberId}:${globalThis.crypto?.randomUUID?.() || Date.now()}` + const operationId = `${setupId}:grant` + + const revokeFreshGrant = async () => { + if (!grant) { + return + } + + await requestHostedConnection(peerRoute, 'groups.peer.revoke_exact', { + grant, + profile: targetProfile + }) + } + + if (grant) { + try { + await addHostedRoomCleanup({ + operationId, + setupId, + kind: 'peer-revoke-exact', + connectionId: peerConnectionId, + profile: targetProfile, + grant, + roomId: null, + cancelId: null, + homeConnectionId: null, + homeProfile: null, + memberId: null, + targetUrl: null, + catalog: null + }) + } catch (error) { + await revokeFreshGrant() + throw error + } + } + + const abandonIfStale = async () => { + if (hostedRoomLifecycleIsCurrent(lifecycle)) { + return + } + + await armHostedRoomCleanup(setupId) + await dispatchHostedRoomCleanup() + assertCurrent() + } + + await abandonIfStale() + + let grantSha256 = '' + + if (grant) { + try { + grantSha256 = await sha256(grant) + await abandonIfStale() + } catch (error) { + await armHostedRoomCleanup(setupId) + await dispatchHostedRoomCleanup() + throw error + } + } + + if ( + !grant || + !catalog?.installation_id || + String(catalog.installation_id) !== targetAuthority || + !catalog.catalog_digest || + !targetProfile || + targetProfile !== profile || + !targetUrl + ) { + if (grant) { + await armHostedRoomCleanup(setupId) + await dispatchHostedRoomCleanup() + } + throw new Error('That Bot gateway could not prepare a secure connection.') + } + + try { + await addHostedRoomCleanup({ + operationId, + setupId, + kind: 'peer-reconnect', + connectionId: peerConnectionId, + profile: targetProfile, + grant, + grantSha256, + expectedGrantSha256, + roomId, + cancelId: null, + homeConnectionId: String(homeRoute.connectionId || ''), + homeProfile: String(homeRoute.targetProfile || homeRoute.profile || 'default'), + memberId, + targetUrl, + catalog + }) + } catch (error) { + await armHostedRoomCleanup(setupId) + await dispatchHostedRoomCleanup() + throw error + } + + await abandonIfStale() + + try { + await requestHostedConnection(homeRoute, 'groups.peer.register', { + room_id: roomId, + member_id: memberId, + target_url: targetUrl, + target_profile: targetProfile, + grant, + catalog, + expected_grant_sha256: expectedGrantSha256 + }) + await abandonIfStale() + } catch (error) { + await armHostedRoomCleanup(setupId) + await dispatchHostedRoomCleanup() + throw error + } + + await abandonIfStale() + await releaseHostedRoomCleanup(setupId).catch(() => undefined) + assertCurrent() + invalidateHostedRoomPoll(roomId) + await refreshHostedRooms().catch(() => undefined) +} + +export function reconnectHostedGroupChatPeer(group: string, memberId: string) { + const key = `${group}:${memberId}` + const lifecycle = hostedRoomLifecycleToken() + const existing = reconnectingPeers.get(key) + + if (existing?.lifecycle === lifecycle) { + return existing.task + } + + const task = reconnectPeer(group, memberId, lifecycle).finally(() => { + if (reconnectingPeers.get(key)?.task === task) { + reconnectingPeers.delete(key) + } + }) + + reconnectingPeers.set(key, { lifecycle, task }) + + return task +} diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-reconnect-runtime.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-reconnect-runtime.test.ts new file mode 100644 index 0000000000000..e4b80775bbaaa --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-reconnect-runtime.test.ts @@ -0,0 +1,602 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as groupChat from './group-chat' +import type * as groupRounds from './group-rounds' +import { pluginSdkMock, scriptedStorage } from './group-test-utils' +import type * as hostedRuntime from './hosted-room-runtime' +import type { GroupChat, GroupMember } from './types' + +const { host } = vi.hoisted(() => ({ + host: {} as Record +})) + +vi.mock('@hermes/plugin-sdk', async () => pluginSdkMock(host)) + +interface RpcCall { + connectionId?: string + method: string + params: Record +} + +interface RuntimeRoom { + chat: typeof groupChat + calls: RpcCall[] + rounds: typeof groupRounds + runtime: typeof hostedRuntime + storage: Map +} + +const MEMBERS: GroupMember[] = [ + { + name: 'research', + connectionId: 'gateway-a', + sourceScoped: true, + targetProfile: 'research' + }, + { + name: 'builder', + connectionId: 'gateway-a', + sourceScoped: true, + targetProfile: 'builder' + } +] + +function room(overrides: Partial = {}): GroupChat { + return { + log: [], + watermarks: {}, + members: MEMBERS, + roomId: 'room-1', + hosted: 'install:home', + hostedEpoch: 1, + hostedConnectionId: 'gateway-a', + hostedSeq: 0, + continuityMode: 'gateway', + ...overrides + } +} + +function hostedEvent( + seq: number, + eventId: string, + kind: string, + payload: Record = {}, + actor: Record = { + kind: 'gateway', + id: 'install:home' + } +) { + return { + room_id: 'room-1', + seq, + event_id: eventId, + kind, + actor, + payload, + created_at: seq + } +} + +async function loadRuntime( + handler: ( + method: string, + params: Record, + route?: Record + ) => Promise | unknown, + routes: Array> = [ + { + connectionId: 'gateway-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'default' + } + ] +): Promise { + vi.resetModules() + const calls: RpcCall[] = [] + const storage = new Map() + + for (const key of Object.keys(host)) { + delete host[key] + } + + Object.assign(host, { + activeConnectionId: () => 'gateway-a', + notify: vi.fn(), + profileRoutes: async () => routes, + request: async (method: string, params: Record) => { + calls.push({ + method, + params + }) + + return handler(method, params) + }, + requestProfile: async (route: Record, method: string, params: Record) => { + calls.push({ + connectionId: String(route?.connectionId || ''), + method, + params + }) + + return handler(method, params, route) + }, + state: { + connectionId: { + get: () => 'gateway-a', + listen: () => () => undefined + }, + gateway: { + get: () => 'open', + listen: () => () => undefined + }, + profile: { + get: () => 'default', + listen: () => () => undefined + } + } + }) + + const [chat, rounds, runtime, shared] = await Promise.all([ + import('./group-chat'), + import('./group-rounds'), + import('./hosted-room-runtime'), + import('./shared') + ]) + + shared.setPluginCtx(scriptedStorage(storage)) + + return { + chat, + calls, + rounds, + runtime, + storage + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +describe('hosted Group Chat runtime', () => { + it('does not restart cleanup after stop wins a pending storage load', async () => { + let releaseLoad: (value: unknown) => void = () => undefined + let loadStarted: () => void = () => undefined + const started = new Promise(resolve => { + loadStarted = resolve + }) + const pending = new Promise(resolve => { + releaseLoad = resolve + }) + const loaded = await loadRuntime(method => { + throw new Error(`unexpected method after stop: ${method}`) + }) + const get = vi.fn(async (key: string) => { + if (key === 'hosted-room-outbox-v1') { + loadStarted() + + return pending + } + + return null + }) + const storage = { + get, + set: vi.fn() + } + + const start = loaded.runtime.startHostedRoomRuntime(storage as never) + await started + loaded.runtime.stopHostedRoomRuntime() + releaseLoad(null) + await start + + expect(get).toHaveBeenCalledTimes(1) + expect(get).toHaveBeenCalledWith('hosted-room-outbox-v1', null) + expect(loaded.calls).toEqual([]) + }) + + it('does not let a pre-stop refresh rejection mark a restarted runtime offline', async () => { + let releaseState: () => void = () => undefined + let stateStarted: () => void = () => undefined + const stateRequested = new Promise(resolve => { + stateStarted = resolve + }) + const staleState = new Promise>((_resolve, reject) => { + releaseState = () => reject(new Error('old connection closed')) + }) + let stateCalls = 0 + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { authority_gateway_id: 'install:home', driver: true, persistent_process: true } + } + + if (method === 'groups.list') { + return { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: 0, + members: MEMBERS, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + stateCalls += 1 + if (stateCalls === 1) { + stateStarted() + + return staleState + } + + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + return { events: [], has_more: false, latest_seq: 0 } + } + + throw new Error(`unexpected method: ${method}`) + }) + const storage = scriptedStorage(loaded.storage).storage + + loaded.chat.$groupChats.set({ Release: room() }) + const firstStart = loaded.runtime.startHostedRoomRuntime(storage) + await stateRequested + loaded.runtime.stopHostedRoomRuntime() + const secondStart = loaded.runtime.startHostedRoomRuntime(storage) + releaseState() + await Promise.all([firstStart, secondStart]) + + expect(stateCalls).toBe(2) + expect(loaded.chat.$groupChats.get().Release.hostedStatus?.state).toBe('ready') + loaded.runtime.stopHostedRoomRuntime() + }) + + it('surfaces an explicit reconnect action when a peer route needs reauthorization', async () => { + const serverMembers = [ + { + member_id: 'research', + profile: 'research' + }, + { + display_name: 'Remote Builder', + handle: 'builder', + member_id: 'builder', + profile: 'builder', + target: { + installation_id: 'install:peer', + kind: 'peer', + peer_id: 'install:peer' + } + } + ] + const loaded = await loadRuntime( + (method, _params, route) => { + const connectionId = String(route?.connectionId || '') + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: connectionId === 'gateway-b' ? 'install:peer' : 'install:home', + driver: true, + features: connectionId === 'gateway-a' ? ['peer_route_grant_fingerprint'] : [], + max_log_limit: 100, + methods: connectionId === 'gateway-b' ? ['groups.peer.revoke_exact'] : [], + persistent_process: true + } + } + + if (method === 'groups.list') { + if (connectionId === 'gateway-b') { + return { rooms: [] } + } + + return { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: 0, + members: serverMembers, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + return { + driver_status: { + blocked: true, + peer_routes: [ + { + member_id: 'builder', + status: 'needs_reauthorization' + } + ], + working: false + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: serverMembers, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + return { events: [], has_more: false, latest_seq: 0 } + } + + throw new Error(`unexpected method: ${method}`) + }, + [ + { connectionId: 'gateway-a', mode: 'remote', profile: 'default', targetProfile: 'default' }, + { connectionId: 'gateway-b', mode: 'remote', profile: 'default', targetProfile: 'default' } + ] + ) + + loaded.chat.$groupChats.set({ Release: room() }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + await loaded.runtime.refreshHostedRooms() + + expect(loaded.chat.$groupChats.get().Release).toMatchObject({ + continuityIssue: 'Reconnect Remote Builder to continue this Group Chat.', + hostedStatus: { + canReconnect: true, + canRetry: false, + canStop: false, + label: 'Remote Builder needs your attention.', + reconnectMemberId: 'builder', + state: 'needs-attention' + }, + running: false + }) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('uses the stored member route to explain an older peer gateway without polling forever', async () => { + let peerUpgraded = false + let stateCalls = 0 + const serverMembers = [ + { member_id: 'research', profile: 'research' }, + { + display_name: 'Remote Builder', + handle: 'builder', + member_id: 'builder', + profile: 'builder', + target: { + installation_id: 'install:peer', + kind: 'peer', + peer_id: 'install:peer' + } + } + ] + const loaded = await loadRuntime( + (method, _params, route) => { + const connectionId = String(route?.connectionId || '') + + if (method === 'groups.capabilities') { + if (connectionId === 'gateway-b') { + if (!peerUpgraded) { + throw Object.assign(new Error('Method not found'), { code: -32601 }) + } + + return { + authority_gateway_id: 'install:peer', + driver: true, + methods: ['groups.peer.revoke_exact'], + persistent_process: true + } + } + + return { + authority_gateway_id: 'install:home', + driver: true, + features: ['peer_route_grant_fingerprint'], + persistent_process: true + } + } + + if (method === 'groups.list') { + return connectionId === 'gateway-b' + ? { rooms: [] } + : { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: 0, + members: serverMembers, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + stateCalls += 1 + + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'needs_reauthorization' }], + working: false + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: serverMembers, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + return { events: [], has_more: false, latest_seq: 0 } + } + + throw new Error(`unexpected method: ${method}`) + }, + [ + { connectionId: 'gateway-a', mode: 'remote', profile: 'default', targetProfile: 'default' }, + { connectionId: 'gateway-b', mode: 'remote', profile: 'default', targetProfile: 'default' } + ] + ) + + loaded.chat.$groupChats.set({ + Release: room({ + members: [ + MEMBERS[0], + { + connectionId: 'gateway-b', + handle: 'builder', + name: 'builder', + route: { + connectionId: 'gateway-b', + mode: 'remote', + profile: 'builder', + targetProfile: 'builder' + }, + sourceScoped: true, + targetProfile: 'builder' + } + ] + }) + }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.chat.$groupChats.get().Release.continuityIssue).toMatch(/^Update /) + expect(stateCalls).toBe(1) + + await loaded.runtime.refreshHostedRooms() + expect(stateCalls).toBe(1) + + peerUpgraded = true + vi.setSystemTime(new Date(Date.now() + 31_000)) + await loaded.runtime.refreshHostedRooms() + + expect(stateCalls).toBe(2) + expect(loaded.chat.$groupChats.get().Release.continuityIssue).toBe( + 'Reconnect Remote Builder to continue this Group Chat.' + ) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('does not let an in-flight poll restore a cache entry after invalidation', async () => { + let releaseState: () => void = () => undefined + let stateStarted: () => void = () => undefined + const stateRequested = new Promise(resolve => { + stateStarted = resolve + }) + const heldState = new Promise>(resolve => { + releaseState = () => + resolve({ + driver_status: { working: true }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + }) + }) + let stateCalls = 0 + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { authority_gateway_id: 'install:home', driver: true, persistent_process: true } + } + + if (method === 'groups.list') { + return { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: 0, + members: MEMBERS, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + stateCalls += 1 + if (stateCalls === 1) { + stateStarted() + + return heldState + } + + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + return { events: [], has_more: false, latest_seq: 0 } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ Release: room() }) + const start = loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + await stateRequested + loaded.runtime.invalidateHostedRoomPoll('room-1') + releaseState() + await start + + expect(loaded.chat.$groupChats.get().Release.hostedStatus?.state).toBe('working') + await loaded.runtime.refreshHostedRooms() + expect(stateCalls).toBe(2) + expect(loaded.chat.$groupChats.get().Release.hostedStatus?.state).toBe('ready') + loaded.runtime.stopHostedRoomRuntime() + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts index 13c70e7437959..69d50b97ef071 100644 --- a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts @@ -19,6 +19,16 @@ import { uniqueGroupChatName, updateGroupChat } from './group-chat' +import { + addHostedRoomCleanup, + armHostedRoomCleanup, + dispatchHostedRoomCleanup, + hostedRoomCleanupPending, + releaseHostedRoomCleanup, + resetHostedRoomCleanupForTests, + startHostedRoomCleanup, + stopHostedRoomCleanup +} from './hosted-room-cleanup' import { classifyHostedRoomCapability, createHostedRoomOutbox, @@ -42,11 +52,10 @@ import { botsText } from './i18n' import { requestForBot } from './routing' import type { GroupChat, GroupMember, GroupMessage, ProfileRoute } from './types' +export { $hostedRoomCleanup } from './hosted-room-cleanup' export { describeAutonomousRoomPlan, describeHostedRoomCreationError } from './hosted-room-client' const HOSTED_ROOM_OUTBOX_KEY = 'hosted-room-outbox-v1' -const HOSTED_ROOM_CLEANUP_KEY = 'hosted-room-cleanup-v1' -const HOSTED_ROOM_CLEANUP_LIMIT = 64 const HOSTED_ROOM_LIST_PAGE_SIZE = 500 const HOSTED_ROOM_LIST_MAX_PAGES = 4 const HOSTED_ROOM_SYNC_INTERVAL_MS = 5000 @@ -54,22 +63,30 @@ const HOSTED_ROOM_UNSUPPORTED_REPROBE_MS = 30_000 export const $hostedRoomCapabilities = atom>({}) export const $hostedRoomOutbox = atom(createHostedRoomOutbox()) -export const $hostedRoomCleanup = atom({ version: 1, operations: [] }) const hostedAuthorityRoutes = new Map() const hostedRoomPollCache = new Map() +const hostedRoomPollGenerations = new Map() const hostedRoomMutationGenerations = new Map() const hostedRoomLocallyDeleted = new Set() const hostedRoomInventoriedConnections = new Set() let hostedRoomSyncTimer: ReturnType | null = null let hostedRoomSyncRunning = false let hostedRoomSyncDisposed = true +let hostedRoomLifecycleGeneration = 0 let hostedOutboxDispatching = false -let hostedCleanupDispatching = false let hostedRoomStorage: null | PluginContext['storage'] = null let hostedRoomHooks: HostedRoomRuntimeHooks = {} const hostedUnsupportedUntil = new Map() +export function hostedRoomLifecycleToken() { + return hostedRoomLifecycleGeneration +} + +export function hostedRoomLifecycleIsCurrent(token: number) { + return !hostedRoomSyncDisposed && token === hostedRoomLifecycleGeneration +} + function hostedRoomMutationGeneration(roomId: string) { return Math.max(0, Number(hostedRoomMutationGenerations.get(String(roomId || '')) || 0)) } @@ -145,23 +162,6 @@ export interface HostedRoomProbe { routes: Record } -interface HostedRoomCleanupOperation { - cancelId?: null | string - connectionId: string - grant?: null | string - kind: 'home-disband' | 'peer-revoke' - operationId: string - ownerId: string - profile?: null | string - roomId?: null | string - setupId: string -} - -interface HostedRoomCleanup { - operations: HostedRoomCleanupOperation[] - version: 1 -} - interface HostedRoomCreateInput { members: Array<{ display_name?: string @@ -236,7 +236,7 @@ async function hostedDefaultRoutes(): Promise { return [...byConnection.values()] } -async function requestHostedConnection( +export async function requestHostedConnection( route: ProfileRoute, method: string, params: Record = {} @@ -248,188 +248,6 @@ async function requestHostedConnection( return host.requestProfile(route, method, params) as Promise } -const hostedCleanupOwnerId = - globalThis.crypto?.randomUUID?.() || `desktop-${Date.now()}-${Math.random().toString(36).slice(2)}` - -function normalizeHostedRoomCleanup(value: unknown): HostedRoomCleanup { - const candidate = record(value) - const operations: HostedRoomCleanupOperation[] = [] - - for (const raw of Array.isArray(candidate?.operations) ? candidate.operations : []) { - const operation = record(raw) - const operationId = String(operation?.operationId || '') - const setupId = String(operation?.setupId || '') - const kind = String(operation?.kind || '') - const connectionId = String(operation?.connectionId || '') - - if (!operationId || !setupId || !connectionId || !['home-disband', 'peer-revoke'].includes(kind)) { - continue - } - - if (kind === 'home-disband' && !String(operation?.roomId || '')) { - continue - } - - if (kind === 'peer-revoke' && (!String(operation?.grant || '') || !String(operation?.profile || ''))) { - continue - } - - operations.push({ - operationId, - setupId, - kind: kind as HostedRoomCleanupOperation['kind'], - connectionId, - ownerId: String(operation?.ownerId || ''), - roomId: kind === 'home-disband' ? String(operation?.roomId || '') : null, - cancelId: - kind === 'home-disband' ? String(operation?.cancelId || `rollback-${String(operation?.roomId || '')}`) : null, - profile: kind === 'peer-revoke' ? String(operation?.profile || '') : null, - grant: kind === 'peer-revoke' ? String(operation?.grant || '') : null - }) - } - - return { - version: 1, - operations: operations.slice(-HOSTED_ROOM_CLEANUP_LIMIT) - } -} - -async function replaceHostedRoomCleanup(next: HostedRoomCleanup) { - if (!hostedRoomStorage?.set) { - throw new Error('Desktop storage is unavailable, so Group Chat setup cannot be secured.') - } - - const previous = $hostedRoomCleanup.get() - - $hostedRoomCleanup.set(next) - - try { - await hostedRoomStorage.set(HOSTED_ROOM_CLEANUP_KEY, next) - } catch (error) { - $hostedRoomCleanup.set(previous) - throw error - } -} - -async function addHostedRoomCleanup(operation: Omit) { - const current = normalizeHostedRoomCleanup($hostedRoomCleanup.get()) - - const next = normalizeHostedRoomCleanup({ - version: 1, - operations: [ - ...current.operations.filter(entry => entry.operationId !== operation.operationId), - { - ...operation, - ownerId: hostedCleanupOwnerId - } - ] - }) - - if (next.operations.length >= HOSTED_ROOM_CLEANUP_LIMIT && current.operations.length >= HOSTED_ROOM_CLEANUP_LIMIT) { - throw new Error('Group Chat cleanup is pending. Reconnect the affected devices before creating another.') - } - - await replaceHostedRoomCleanup(next) -} - -async function releaseHostedRoomCleanup(setupId: string) { - const current = normalizeHostedRoomCleanup($hostedRoomCleanup.get()) - - await replaceHostedRoomCleanup({ - version: 1, - operations: current.operations.filter(operation => operation.setupId !== setupId) - }) -} - -async function armHostedRoomCleanup(setupId: string) { - const current = normalizeHostedRoomCleanup($hostedRoomCleanup.get()) - - await replaceHostedRoomCleanup({ - version: 1, - operations: current.operations.map(operation => - operation.setupId === setupId - ? { - ...operation, - ownerId: '' - } - : operation - ) - }) -} - -async function hostedRouteForReference(connectionId: string, profile = 'default') { - if (typeof host.profileRoutes !== 'function') { - return null - } - - const routes = await host.profileRoutes() - - return ((Array.isArray(routes) ? routes : []).find(route => { - const routeProfile = String(route?.targetProfile || route?.profile || '') - - return String(route?.connectionId || '') === connectionId && routeProfile === profile - }) || null) as ProfileRoute | null -} - -function hostedCleanupAlreadySettled(operation: HostedRoomCleanupOperation, error: unknown) { - const candidate = record(error) - const inner = record(candidate?.error) - const code = Number(candidate?.code ?? inner?.code) - const message = String(candidate?.message || inner?.message || error || '') - - return operation.kind === 'home-disband' && code === 4113 && /hosted room not found|already disbanded/i.test(message) -} - -async function dispatchHostedRoomCleanup() { - if (hostedCleanupDispatching || hostedRoomSyncDisposed) { - return - } - - hostedCleanupDispatching = true - - try { - for (const operation of normalizeHostedRoomCleanup($hostedRoomCleanup.get()).operations) { - if (operation.ownerId === hostedCleanupOwnerId) { - continue - } - - const profile = operation.kind === 'peer-revoke' ? String(operation.profile || '') : 'default' - const route = await hostedRouteForReference(operation.connectionId, profile) - - if (!route) { - continue - } - - try { - if (operation.kind === 'home-disband') { - await requestHostedConnection(route, 'groups.disband', { - room_id: operation.roomId, - cancel_id: operation.cancelId - }) - } else { - await requestHostedConnection(route, 'groups.peer.revoke', { - grant: operation.grant, - profile: operation.profile - }) - } - } catch (error) { - if (!hostedCleanupAlreadySettled(operation, error)) { - continue - } - } - - const latest = normalizeHostedRoomCleanup($hostedRoomCleanup.get()) - - await replaceHostedRoomCleanup({ - version: 1, - operations: latest.operations.filter(entry => entry.operationId !== operation.operationId) - }) - } - } finally { - hostedCleanupDispatching = false - } -} - async function withHostedRoomProbeTimeout(task: Promise, timeoutMs = 3000) { let timer: null | ReturnType = null @@ -465,8 +283,7 @@ function hostedMemberDescriptors( const handle = String(member.handle || member.profile || 'hermes') const target = record(member.target) - const targetAuthority = - target?.kind === 'peer' ? String(target.installation_id || target.peer_id || '') : '' + const targetAuthority = target?.kind === 'peer' ? String(target.installation_id || target.peer_id || '') : '' const prior = (existingMembers || []).find( candidate => @@ -480,9 +297,7 @@ function hostedMemberDescriptors( const connectionId = targetAuthority ? peerConnectionId || String(prior?.connectionId || '') : homeConnectionId - const connectionLabel = connectionId - ? sourceLabel(connectionId) - : String(prior?.connectionLabel || '') + const connectionLabel = connectionId ? sourceLabel(connectionId) : String(prior?.connectionLabel || '') const sourceReachable = connectionId ? capabilities[connectionId] @@ -637,6 +452,40 @@ export function hostedRoomPollFingerprint(value: unknown) { return `${revision}:${latestSeq}` } +function hostedRoomCapabilityFingerprint(capability: HostedRoomCapability | undefined) { + if (!capability) { + return '' + } + + return JSON.stringify([ + capability.kind, + capability.authorityId, + capability.persistentProcess, + capability.exactPeerGrantRevoke, + capability.routeGrantFingerprint + ]) +} + +function invalidateHostedRoomsForConnection(connectionId: string) { + for (const room of Object.values($groupChats.get())) { + if ( + room.hostedConnectionId === connectionId || + (room.members || []).some( + member => String(member.route?.connectionId || member.connectionId || '') === connectionId + ) + ) { + hostedRoomPollCache.delete(String(room.roomId || '')) + } + } +} + +export function invalidateHostedRoomPoll(roomId: string) { + const id = String(roomId || '') + + hostedRoomPollCache.delete(id) + hostedRoomPollGenerations.set(id, Number(hostedRoomPollGenerations.get(id) || 0) + 1) +} + export function shouldRefreshHostedRoom(room: GroupChat | undefined, listed: unknown) { if (!room) { return true @@ -662,6 +511,9 @@ export async function refreshHostedRooms() { return } + const lifecycleGeneration = hostedRoomLifecycleGeneration + const syncStale = () => hostedRoomSyncDisposed || lifecycleGeneration !== hostedRoomLifecycleGeneration + hostedRoomSyncRunning = true try { @@ -672,7 +524,7 @@ export async function refreshHostedRooms() { } for (const route of routes) { - if (hostedRoomSyncDisposed) { + if (syncStale()) { return } @@ -707,11 +559,24 @@ export async function refreshHostedRooms() { } } - if (hostedRoomSyncDisposed) { + if (syncStale()) { return } + if (hostedRoomCapabilityFingerprint(cached) !== hostedRoomCapabilityFingerprint(capability)) { + invalidateHostedRoomsForConnection(connectionId) + } + capabilities[connectionId] = capability + } + + if (syncStale()) { + return + } + + for (const route of routes) { + const connectionId = String(route.connectionId) + const capability = capabilities[connectionId] if (!isHostedRoomContinuityEligible(capability) || !capability.authorityId) { if (capability.kind === 'unsupported') { @@ -751,12 +616,15 @@ export async function refreshHostedRooms() { listOffset = nextOffset } } catch { + if (syncStale()) { + return + } markHostedConnectionUnavailable(connectionId) continue } - if (hostedRoomSyncDisposed) { + if (syncStale()) { return } @@ -793,8 +661,7 @@ export async function refreshHostedRooms() { if (!shouldRefreshHostedRoom(existingEntry?.[1], listedRoom)) { if ( includeDisbanded && - Math.max(0, Number(existingEntry?.[1]?.hostedSeq || 0)) >= - Math.max(0, Number(listedRoom.latest_seq || 0)) + Math.max(0, Number(existingEntry?.[1]?.hostedSeq || 0)) >= Math.max(0, Number(listedRoom.latest_seq || 0)) ) { caughtUpDisbandedIds.add(roomId) } @@ -803,6 +670,7 @@ export async function refreshHostedRooms() { } const refreshGeneration = hostedRoomMutationGeneration(roomId) + const pollGeneration = Number(hostedRoomPollGenerations.get(roomId) || 0) let stateResponse: Record @@ -812,12 +680,15 @@ export async function refreshHostedRooms() { ...(includeDisbanded ? { include_disbanded: true } : {}) }) } catch { + if (syncStale()) { + return + } markHostedConnectionUnavailable(connectionId) continue } - if (hostedRoomSyncDisposed) { + if (syncStale()) { return } @@ -866,7 +737,7 @@ export async function refreshHostedRooms() { existing = $groupChats.get()[renamed] } - if (hostedRoomSyncDisposed) { + if (syncStale()) { return } @@ -895,7 +766,7 @@ export async function refreshHostedRooms() { pageSize: capability.maxLogLimit || 100 }) - if (hostedRoomSyncDisposed) { + if (syncStale()) { return } @@ -905,6 +776,42 @@ export async function refreshHostedRooms() { const replayStatus = deriveFriendlyHostedRoomStatus(replay.state) const driver = record(stateResponse.driver_status) + const reconnectRoute = (Array.isArray(driver?.peer_routes) ? driver.peer_routes : []) + .map(record) + .find(route => route?.status === 'needs_reauthorization' && String(route?.member_id || '')) + const reconnectMemberId = String(reconnectRoute?.member_id || '') + const reconnectMember = (Array.isArray(serverRoom.members) ? serverRoom.members : []) + .map(record) + .find(member => String(member?.member_id || '') === reconnectMemberId) + const reconnectName = String( + reconnectMember?.display_name || reconnectMember?.handle || reconnectMember?.profile || botsText().group.aBot + ) + const reconnectTarget = record(reconnectMember?.target) + const reconnectAuthority = String(reconnectTarget?.installation_id || reconnectTarget?.peer_id || '') + const reconnectPrior = (existing?.members || []).find( + member => + String(member.handle || member.name || '') === + String(reconnectMember?.handle || reconnectMember?.profile || '') && + String(member.targetProfile || member.name || '') === + String(reconnectMember?.profile || reconnectMember?.member_id || '') + ) + const reconnectConnectionId = + Object.entries(capabilities).find(([, candidate]) => candidate.authorityId === reconnectAuthority)?.[0] || + String(reconnectPrior?.route?.connectionId || reconnectPrior?.connectionId || '') + const reconnectCapability = reconnectConnectionId ? capabilities[reconnectConnectionId] : undefined + const reconnectCapabilityKnown = Boolean(reconnectCapability) + const reconnectSupported = Boolean( + capability.routeGrantFingerprint && + reconnectConnectionId && + reconnectCapability?.kind === 'driver-capable' && + reconnectCapability.exactPeerGrantRevoke + ) + const reconnectUpdateConnectionId = !capability.routeGrantFingerprint + ? connectionId + : reconnectCapability?.kind === 'unsupported' || + (reconnectCapability?.kind === 'driver-capable' && !reconnectCapability.exactPeerGrantRevoke) + ? reconnectConnectionId + : '' const stopping = $hostedRoomOutbox .get() @@ -913,7 +820,15 @@ export async function refreshHostedRooms() { command.roomId === roomId && ['disband', 'stop'].includes(command.kind) && command.status !== 'failed' ) - const friendly = hostedRoomDriverDisplayStatus(replayStatus, driver, { stopping }) + const friendly = reconnectMemberId + ? { + ...replayStatus, + kind: 'needs-attention' as const, + member: reconnectName, + canRetry: false, + canStop: false + } + : hostedRoomDriverDisplayStatus(replayStatus, driver, { stopping }) const running = ['queued', 'stopping', 'working'].includes(friendly.kind) const retryAction = (Array.isArray(driver?.pending_actions) ? driver.pending_actions : []) @@ -935,10 +850,26 @@ export async function refreshHostedRooms() { hostedStatus: { ...hostedStatus(friendly, sourceLabel(connectionId)), ...(retryAction ? { taskId: String(retryAction.task_id) } : {}), - ...(!replay.complete ? { canRetry: true } : {}) + ...(reconnectMemberId && reconnectSupported + ? { + canReconnect: true, + reconnectMemberId + } + : {}), + ...(!replay.complete && !reconnectMemberId ? { canRetry: true } : {}) }, continuityMode: hostedRoomContinuityMode(serverRoom), - continuityIssue: replay.complete ? null : botsText().group.hostedSyncing, + continuityIssue: reconnectMemberId + ? !reconnectCapabilityKnown || reconnectCapability?.kind === 'transient-failure' + ? botsText().group.hostedSyncing + : reconnectSupported + ? botsText().group.memberReconnectToContinue(reconnectName) + : botsText().group.hostUpdateNeeded( + reconnectUpdateConnectionId ? sourceLabel(reconnectUpdateConnectionId) : reconnectName + ) + : replay.complete + ? null + : botsText().group.hostedSyncing, running } }, @@ -947,7 +878,11 @@ export async function refreshHostedRooms() { } ) - if (replay.complete) { + if ( + replay.complete && + (!reconnectMemberId || Boolean(reconnectUpdateConnectionId)) && + Number(hostedRoomPollGenerations.get(roomId) || 0) === pollGeneration + ) { hostedRoomPollCache.set(roomId, hostedRoomPollFingerprint(listedRoom)) if (includeDisbanded) { @@ -1008,6 +943,9 @@ export async function refreshHostedRooms() { continue } catch (error) { + if (syncStale()) { + return + } const message = String(record(error)?.message || record(record(error)?.error)?.message || error || '') if (!/history expired|permanently retired|hosted room not found/i.test(message)) { @@ -1033,7 +971,7 @@ export async function refreshHostedRooms() { } } - if (!hostedRoomSyncDisposed) { + if (!syncStale()) { $hostedRoomCapabilities.set(capabilities) } } finally { @@ -1479,9 +1417,7 @@ export async function createAutonomousHostedGroupChat({ await armHostedRoomCleanup(roomId).catch(() => undefined) await dispatchHostedRoomCleanup().catch(() => undefined) - if ( - normalizeHostedRoomCleanup($hostedRoomCleanup.get()).operations.some(operation => operation.setupId === roomId) - ) { + if (hostedRoomCleanupPending(roomId)) { throw Object.assign( new Error('Some selected Bots could not finish cleanup. Reconnect them before trying again.', { cause: error @@ -1646,6 +1582,7 @@ export async function disbandHostedGroupChat(group: string) { } export async function startHostedRoomRuntime(storage: PluginContext['storage'], hooks: HostedRoomRuntimeHooks = {}) { + const lifecycleGeneration = ++hostedRoomLifecycleGeneration hostedRoomStorage = storage hostedRoomHooks = hooks hostedRoomSyncDisposed = false @@ -1660,30 +1597,34 @@ export async function startHostedRoomRuntime(storage: PluginContext['storage'], /* an empty outbox is the safe fallback */ } + if (hostedRoomSyncDisposed || lifecycleGeneration !== hostedRoomLifecycleGeneration) { + return + } + try { $hostedRoomOutbox.set(createHostedRoomOutbox(persisted)) } catch { $hostedRoomOutbox.set(createHostedRoomOutbox()) } - try { - $hostedRoomCleanup.set(normalizeHostedRoomCleanup(await storage?.get?.(HOSTED_ROOM_CLEANUP_KEY, null))) - } catch { - $hostedRoomCleanup.set({ version: 1, operations: [] }) + await startHostedRoomCleanup(storage) + if (hostedRoomSyncDisposed || lifecycleGeneration !== hostedRoomLifecycleGeneration) { + return } - - await dispatchHostedRoomCleanup().catch(() => undefined) await refreshHostedRooms().catch(() => undefined) await dispatchHostedRoomOutbox().catch(() => undefined) scheduleHostedRoomSync() } export function stopHostedRoomRuntime() { + hostedRoomLifecycleGeneration += 1 hostedRoomSyncDisposed = true + stopHostedRoomCleanup() hostedRoomStorage = null hostedRoomHooks = {} hostedAuthorityRoutes.clear() hostedRoomPollCache.clear() + hostedRoomPollGenerations.clear() hostedRoomMutationGenerations.clear() hostedRoomLocallyDeleted.clear() hostedRoomInventoriedConnections.clear() @@ -1701,8 +1642,7 @@ export function resetHostedRoomRuntimeForTests() { stopHostedRoomRuntime() hostedRoomSyncRunning = false hostedOutboxDispatching = false - hostedCleanupDispatching = false + resetHostedRoomCleanupForTests() $hostedRoomCapabilities.set({}) $hostedRoomOutbox.set(createHostedRoomOutbox()) - $hostedRoomCleanup.set({ version: 1, operations: [] }) } diff --git a/apps/desktop/src/plugins/hermes-bots/i18n.ts b/apps/desktop/src/plugins/hermes-bots/i18n.ts index 5a12f14f1f7c0..4904cea0077f5 100644 --- a/apps/desktop/src/plugins/hermes-bots/i18n.ts +++ b/apps/desktop/src/plugins/hermes-bots/i18n.ts @@ -201,10 +201,14 @@ type BotsMessages = { retryTitle: string retryDesc: string retryAction: string + reconnectAction: string + reconnectingAction: string + reconnectFailed: string botsNeedOneHost: string aBot: string memberUnavailable: (member: string) => string memberNeedsAttention: (member: string) => string + memberReconnectToContinue: (member: string) => string memberCouldNotRespond: (member: string) => string memberRetryWhenOnline: (member: string) => string desktopStorageUnavailable: string @@ -451,10 +455,14 @@ const en: BotsMessages = { retryTitle: 'Retry uncertain work?', retryDesc: 'The earlier attempt may have finished. Retrying could repeat actions.', retryAction: 'Retry', + reconnectAction: 'Reconnect', + reconnectingAction: 'Connecting…', + reconnectFailed: 'Could not reconnect this Bot. Check its gateway and try again.', botsNeedOneHost: 'The selected Bots cannot continue when Desktop is closed.', aBot: 'A bot', memberUnavailable: member => `${member} is unavailable.`, memberNeedsAttention: member => `${member} needs your attention.`, + memberReconnectToContinue: member => `Reconnect ${member} to continue this Group Chat.`, memberCouldNotRespond: member => `${member} could not respond.`, memberRetryWhenOnline: member => `${member} will retry when online.`, desktopStorageUnavailable: 'Desktop could not save this action. Try again.', @@ -694,10 +702,14 @@ const ja: BotsMessages = { retryTitle: '不確かな作業を再試行しますか?', retryDesc: '前の試行が完了している可能性があります。再試行すると操作が重複する場合があります。', retryAction: '再試行', + reconnectAction: '再接続', + reconnectingAction: '接続中…', + reconnectFailed: 'このボットを再接続できませんでした。ゲートウェイを確認して、もう一度お試しください。', botsNeedOneHost: '選択したボットはDesktopを閉じると継続できません。', aBot: 'ボット', memberUnavailable: member => `${member} は利用できません。`, memberNeedsAttention: member => `${member} に確認が必要です。`, + memberReconnectToContinue: member => `このグループチャットを続けるには ${member} を再接続してください。`, memberCouldNotRespond: member => `${member} は応答できませんでした。`, memberRetryWhenOnline: member => `${member} はオンラインになると再試行します。`, desktopStorageUnavailable: 'Desktopでこの操作を保存できませんでした。もう一度お試しください。', @@ -935,10 +947,14 @@ const zh: BotsMessages = { retryTitle: '重试状态不确定的工作?', retryDesc: '之前的尝试可能已完成。重试可能会重复操作。', retryAction: '重试', + reconnectAction: '重新连接', + reconnectingAction: '正在连接…', + reconnectFailed: '无法重新连接此机器人。请检查其网关后重试。', botsNeedOneHost: '关闭 Desktop 后,所选机器人无法继续工作。', aBot: '一个机器人', memberUnavailable: member => `${member} 不可用。`, memberNeedsAttention: member => `${member} 需要你的处理。`, + memberReconnectToContinue: member => `请重新连接 ${member} 以继续此群聊。`, memberCouldNotRespond: member => `${member} 无法回复。`, memberRetryWhenOnline: member => `${member} 上线后将重试。`, desktopStorageUnavailable: 'Desktop 无法保存此操作。请重试。', @@ -1176,10 +1192,14 @@ const zhHant: BotsMessages = { retryTitle: '重試狀態不確定的工作?', retryDesc: '先前的嘗試可能已完成。重試可能會重複操作。', retryAction: '重試', + reconnectAction: '重新連接', + reconnectingAction: '正在連接…', + reconnectFailed: '無法重新連接此機器人。請檢查其閘道後再試一次。', botsNeedOneHost: '關閉 Desktop 後,所選機器人無法繼續工作。', aBot: '一個機器人', memberUnavailable: member => `${member} 無法使用。`, memberNeedsAttention: member => `${member} 需要您的處理。`, + memberReconnectToContinue: member => `請重新連接 ${member} 以繼續此群組聊天。`, memberCouldNotRespond: member => `${member} 無法回覆。`, memberRetryWhenOnline: member => `${member} 上線後將重試。`, desktopStorageUnavailable: 'Desktop 無法儲存此操作。請再試一次。', diff --git a/apps/desktop/src/plugins/hermes-bots/types.ts b/apps/desktop/src/plugins/hermes-bots/types.ts index 97be0b2253e17..0ac8b580ec05d 100644 --- a/apps/desktop/src/plugins/hermes-bots/types.ts +++ b/apps/desktop/src/plugins/hermes-bots/types.ts @@ -176,9 +176,11 @@ export interface GroupChat { /** Last contiguous hosted-room event sequence applied locally. */ hostedSeq?: number hostedStatus?: null | { + canReconnect?: boolean canRetry?: boolean canStop?: boolean label: string + reconnectMemberId?: string state: string taskId?: string } diff --git a/gateway/hosted_room_contract.py b/gateway/hosted_room_contract.py index 780a6bf25da0c..2c053f476a74f 100644 --- a/gateway/hosted_room_contract.py +++ b/gateway/hosted_room_contract.py @@ -106,6 +106,7 @@ "expires_at", "revoked_before", }) +_REVOKED_GRANT_ID_SCHEMA_COLUMNS = frozenset({"scope_key", "grant_id", "expires_at"}) _PEER_RESERVATION_SCHEMA_COLUMNS = frozenset({ "room_id", "member_id", diff --git a/gateway/hosted_room_peer.py b/gateway/hosted_room_peer.py index 5cbc102e07544..581066b5008aa 100644 --- a/gateway/hosted_room_peer.py +++ b/gateway/hosted_room_peer.py @@ -814,8 +814,13 @@ def decode_room_grant( *, permission: str, now: float | None = None, + allow_expired_for_revocation: bool = False, ) -> dict[str, Any]: """Verify grant signature, lifetime and operation without a dispatch.""" + if allow_expired_for_revocation and permission != "status": + raise HostedRoomGrantError( + "expired room grants may only be decoded for revocation" + ) if not isinstance(token, str) or len(token.encode("utf-8")) > MAX_TOKEN_BYTES: raise HostedRoomGrantError("room grant is invalid") encoded_token, separator, signature_token = token.partition(".") @@ -856,7 +861,9 @@ def decode_room_grant( if permission in {"approve", "status", "stop"} else expires_at ) - if checked_now < issued_at - 30 or checked_now >= operation_expires_at: + if checked_now < issued_at - 30 or ( + checked_now >= operation_expires_at and not allow_expired_for_revocation + ): raise HostedRoomGrantError("room grant is expired or not active") permissions = payload.get("permissions") if not isinstance(permissions, list) or permission not in permissions: diff --git a/gateway/hosted_room_storage.py b/gateway/hosted_room_storage.py index 4db848854afb5..f72075cd0a94a 100644 --- a/gateway/hosted_room_storage.py +++ b/gateway/hosted_room_storage.py @@ -39,6 +39,7 @@ _REMOTE_RUN_SCHEMA_COLUMNS, _REPLICA_RESERVATION_COLUMNS, _RETIRED_ROOM_SCHEMA_COLUMNS, + _REVOKED_GRANT_ID_SCHEMA_COLUMNS, _REVOKED_GRANT_SCHEMA_COLUMNS, _ROOM_RESERVATION_SCHEMA_COLUMNS, _ROOM_SAFETY_TRIGGERS, @@ -211,6 +212,14 @@ def _initialize_schema(conn: sqlite3.Connection) -> None: revoked_before REAL NOT NULL )""" ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_revoked_grant_ids ( + scope_key TEXT NOT NULL, + grant_id TEXT NOT NULL, + expires_at REAL NOT NULL, + PRIMARY KEY (scope_key, grant_id) + )""" + ) conn.execute( """CREATE TABLE IF NOT EXISTS hosted_room_peer_reservations ( room_id TEXT NOT NULL, @@ -449,6 +458,10 @@ def _schema_is_current(conn: sqlite3.Connection) -> bool: row[1] for row in conn.execute("PRAGMA table_info(hosted_room_revoked_grants)") ) + revoked_grant_id_columns = frozenset( + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_revoked_grant_ids)") + ) peer_reservation_columns = frozenset( row[1] for row in conn.execute("PRAGMA table_info(hosted_room_peer_reservations)") @@ -480,6 +493,13 @@ def _schema_is_current(conn: sqlite3.Connection) -> bool: return False if not _REVOKED_GRANT_SCHEMA_COLUMNS.issubset(revoked_grant_columns): return False + if not _REVOKED_GRANT_ID_SCHEMA_COLUMNS.issubset(revoked_grant_id_columns): + return False + if _primary_key_columns(conn, "hosted_room_revoked_grant_ids") != ( + "scope_key", + "grant_id", + ): + return False if not _PEER_RESERVATION_SCHEMA_COLUMNS.issubset(peer_reservation_columns): return False if not _QUARANTINE_SCHEMA_COLUMNS.issubset(quarantine_columns): @@ -620,6 +640,39 @@ def _room_grant_scope_key(claims: Mapping[str, Any]) -> str: ).hexdigest() +def revoke_room_grant_id( + db_path: Path | str, + *, + claims: Mapping[str, Any], + expires_at: float, + now: float | None = None, +) -> None: + """Revoke only one bearer grant without fencing concurrent replacements.""" + + timestamp = float(now if now is not None else time.time()) + expiry = float(expires_at) + if expiry <= timestamp: + return + grant_id = _validate_identifier( + claims.get("grant_id"), label="grant_id", max_chars=256 + ) + scope_key = _room_grant_scope_key(claims) + with _transaction(db_path, immediate=True) as conn: + conn.execute( + "DELETE FROM hosted_room_revoked_grant_ids WHERE expires_at<=?", + (timestamp,), + ) + conn.execute( + """INSERT INTO hosted_room_revoked_grant_ids( + scope_key, grant_id, expires_at + ) VALUES (?, ?, ?) + ON CONFLICT(scope_key, grant_id) DO UPDATE SET + expires_at=MAX(hosted_room_revoked_grant_ids.expires_at, + excluded.expires_at)""", + (scope_key, grant_id, expiry), + ) + + def revoke_room_grant_scope( db_path: Path | str, *, @@ -846,7 +899,18 @@ def room_grant_is_revoked( timestamp = float(now if now is not None else time.time()) scope_key = _room_grant_scope_key(claims) issued_at = float(claims.get("issued_at") or 0) + grant_id = _validate_identifier( + claims.get("grant_id"), label="grant_id", max_chars=256 + ) + scope_key = _room_grant_scope_key(claims) with _transaction(db_path) as conn: + exact = conn.execute( + """SELECT 1 FROM hosted_room_revoked_grant_ids + WHERE scope_key=? AND grant_id=? AND expires_at>?""", + (scope_key, grant_id, timestamp), + ).fetchone() + if exact is not None: + return True row = conn.execute( """SELECT revoked_before FROM hosted_room_revoked_grants WHERE scope_key=? AND expires_at>?""", diff --git a/gateway/hosted_rooms.py b/gateway/hosted_rooms.py index 63515b0df12aa..b74acfea4387a 100644 --- a/gateway/hosted_rooms.py +++ b/gateway/hosted_rooms.py @@ -82,6 +82,7 @@ prune_disbanded_rooms, remote_run_receipt, reserve_peer_room, + revoke_room_grant_id, revoke_room_grant_scope, room_grant_is_revoked, update_room_link_status, diff --git a/tests/gateway/test_hosted_room_exact_grant_revoke.py b/tests/gateway/test_hosted_room_exact_grant_revoke.py new file mode 100644 index 0000000000000..b773dc2c284aa --- /dev/null +++ b/tests/gateway/test_hosted_room_exact_grant_revoke.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from gateway import hosted_rooms + + +def claims(grant_id: str, issued_at: float) -> dict: + return { + "grant_id": grant_id, + "room_id": "room-1", + "home_install_id": "install:home", + "authority_gateway_id": "install:home", + "authority_epoch": 1, + "member_id": "builder", + "target_install_id": "install:peer", + "target_profile": "builder", + "issued_at": issued_at, + } + + +def test_exact_revoke_preserves_a_concurrent_replacement(tmp_path): + db = tmp_path / "state.db" + losing = claims("grant-losing", 100.0) + winning = claims("grant-winning", 101.0) + other_scope = {**losing, "member_id": "reviewer"} + + hosted_rooms.revoke_room_grant_id( + db, + claims=losing, + expires_at=300.0, + now=110.0, + ) + + assert hosted_rooms.room_grant_is_revoked(db, claims=losing, now=120.0) + assert not hosted_rooms.room_grant_is_revoked(db, claims=winning, now=120.0) + assert not hosted_rooms.room_grant_is_revoked(db, claims=other_scope, now=120.0) + + +def test_scope_revoke_still_fences_all_older_grants(tmp_path): + db = tmp_path / "state.db" + first = claims("grant-first", 100.0) + second = claims("grant-second", 101.0) + + hosted_rooms.revoke_room_grant_scope( + db, + claims=first, + expires_at=300.0, + now=110.0, + ) + + assert hosted_rooms.room_grant_is_revoked(db, claims=first, now=120.0) + assert hosted_rooms.room_grant_is_revoked(db, claims=second, now=120.0) diff --git a/tests/gateway/test_hosted_room_peer.py b/tests/gateway/test_hosted_room_peer.py index 49698c4cc406b..f2fa83ca652b0 100644 --- a/tests/gateway/test_hosted_room_peer.py +++ b/tests/gateway/test_hosted_room_peer.py @@ -19,6 +19,7 @@ PROTOCOL_VERSION, RoomLinkProbe, catalog_mapping, + decode_room_grant, derive_room_grant_secret, gateway_room_grant_secret, issue_room_grant, @@ -291,6 +292,25 @@ def test_room_grant_fails_closed_for_tamper_expiry_and_permission(): with pytest.raises(HostedRoomGrantError, match="signature"): verify_room_grant(SECRET, token[:-1] + "A", dispatch, now=105) + assert ( + decode_room_grant( + SECRET, + token, + permission="status", + now=100 + 30 * 24 * 60 * 60, + allow_expired_for_revocation=True, + )["grant_id"] + == "grant-1" + ) + with pytest.raises(HostedRoomGrantError, match="only.*revocation"): + decode_room_grant( + SECRET, + token, + permission="dispatch", + now=100 + 30 * 24 * 60 * 60, + allow_expired_for_revocation=True, + ) + def test_link_selection_prefers_safe_direct_then_overlay_then_relay_then_pull(): selected = select_room_link( diff --git a/tests/tui_gateway/test_groups_methods.py b/tests/tui_gateway/test_groups_methods.py index 4ab00cdabacd6..ddd8ffa79ddf4 100644 --- a/tests/tui_gateway/test_groups_methods.py +++ b/tests/tui_gateway/test_groups_methods.py @@ -87,11 +87,13 @@ def test_capabilities_are_honest_about_the_driver_boundary(home): def test_capabilities_and_invitation_advertise_scoped_roomlink(home, monkeypatch): monkeypatch.setenv("API_SERVER_KEY", "gateway-api-key-1234567890") monkeypatch.setenv("HERMES_PROFILE", "reviewer") + (home / "profiles" / "reviewer").mkdir() result = _result(srv._methods["groups.capabilities"](1, {})) assert result["room_link"]["enabled"] is True assert result["room_link"]["profile"] == "reviewer" assert result["room_link"]["catalog"]["text"] is True assert "groups.peer.invite" in result["methods"] + assert "groups.peer.revoke_exact" in result["methods"] assert "groups.peer.register" in result["methods"] invitation = _result( @@ -118,6 +120,17 @@ def test_capabilities_and_invitation_advertise_scoped_roomlink(home, monkeypatch target_profile="reviewer", ) + exact = _result( + srv._methods["groups.peer.revoke_exact"]( + 3, + { + "grant": invitation["grant"], + "profile": "reviewer", + }, + ) + ) + assert exact == {"revoked": True} + def test_capabilities_disable_roomlink_when_run_replay_is_not_durable( home, monkeypatch @@ -366,12 +379,14 @@ def register_peer_route(self, **kwargs): "target_profile": "reviewer", "grant": "signed.room.grant", "catalog": catalog, + "expected_grant_sha256": "a" * 64, }, ) ) assert result["registered"] is True assert captured["api_key"] == "" assert captured["registered"]["target_url"] == ("https://peer.example.test") + assert captured["registered"]["expected_grant_sha256"] == "a" * 64 def test_register_rejects_plaintext_non_loopback(home, monkeypatch): diff --git a/tests/tui_gateway/test_hosted_room_grant_fingerprint.py b/tests/tui_gateway/test_hosted_room_grant_fingerprint.py new file mode 100644 index 0000000000000..a409c73f3ac1e --- /dev/null +++ b/tests/tui_gateway/test_hosted_room_grant_fingerprint.py @@ -0,0 +1,116 @@ +"""Focused contract test for reconnect grant identity.""" + +from __future__ import annotations + +import hashlib +import threading +from types import SimpleNamespace + +import pytest + +from tui_gateway.hosted_room_peer_transport import PeerMemberRoute +from tui_gateway.hosted_room_service import HostedRoomService + + +def _route(grant: str) -> PeerMemberRoute: + return PeerMemberRoute( + home_install_id="install-home", + member_id="member-peer", + target_install_id="install-peer", + target_profile="reviewer", + capability_digest="catalog-digest", + cancellation_scope_id="cancel-scope", + trace_id="trace-id", + grant=grant, + ) + + +def _service() -> HostedRoomService: + service = object.__new__(HostedRoomService) + service._policy_lock = threading.RLock() + service._peer_route_status = {("room-1", "member-peer"): "ready"} + service.peer_routes = {("room-1", "member-peer"): _route("signed.room.grant")} + service.peer_clients = {} + service.runtime = SimpleNamespace(wakeup=lambda: None) + service.status = lambda _room_id: { + "working": False, + "peer_routes": service._route_statuses("room-1"), + } + return service + + +def test_route_status_exposes_only_the_grant_fingerprint(): + service = _service() + status = service.status_with_grant_fingerprints("room-1") + + assert status["peer_routes"] == [ + { + "room_id": "room-1", + "member_id": "member-peer", + "status": "ready", + "grant_sha256": hashlib.sha256(b"signed.room.grant").hexdigest(), + } + ] + assert "signed.room.grant" not in repr(status) + + +def test_route_status_and_fingerprint_are_one_locked_snapshot(): + service = _service() + status_started = threading.Event() + rotated = threading.Event() + + def status(_room_id): + status_started.set() + assert not rotated.wait(0.05) + return {"peer_routes": service._route_statuses("room-1")} + + def rotate(): + status_started.wait() + with service._policy_lock: + service.peer_routes[("room-1", "member-peer")] = _route( + "replacement.room.grant" + ) + rotated.set() + + service.status = status + worker = threading.Thread(target=rotate) + worker.start() + snapshot = service.status_with_grant_fingerprints("room-1") + worker.join(timeout=1) + + assert rotated.is_set() + assert snapshot["peer_routes"][0]["grant_sha256"] == hashlib.sha256( + b"signed.room.grant" + ).hexdigest() + + +def test_peer_registration_compares_and_swaps_the_observed_grant(): + service = _service() + old_sha256 = hashlib.sha256(b"signed.room.grant").hexdigest() + winner = _route("winner.room.grant") + + with pytest.raises(RuntimeError, match="changed during reconnect"): + service.register_peer_route( + room_id="room-1", + member_id="member-peer", + route=winner, + client=object(), + expected_grant_sha256="0" * 64, + ) + + assert service.peer_routes[("room-1", "member-peer")].grant == "signed.room.grant" + service.register_peer_route( + room_id="room-1", + member_id="member-peer", + route=winner, + client=object(), + expected_grant_sha256=old_sha256, + ) + service.register_peer_route( + room_id="room-1", + member_id="member-peer", + route=winner, + client=object(), + expected_grant_sha256=old_sha256, + ) + assert service.peer_routes[("room-1", "member-peer")].grant == "winner.room.grant" diff --git a/tui_gateway/hosted_room_service.py b/tui_gateway/hosted_room_service.py index 52943879cabed..9ba8ebfdaca3c 100644 --- a/tui_gateway/hosted_room_service.py +++ b/tui_gateway/hosted_room_service.py @@ -204,6 +204,7 @@ def register_peer_route( client: HostedRoomPeerClient, target_url: str | None = None, catalog: GatewayRoomCatalog | None = None, + expected_grant_sha256: str | None = None, ) -> None: """Register one verified route and optionally persist its scoped grant.""" bind_store = getattr(client, "bind_receipt_store", None) @@ -223,23 +224,37 @@ def register_peer_route( != catalog.execution_policy.policy_digest ): raise ValueError("peer route does not match its target catalog") - if target_url is not None and catalog is not None: - hosted_room_links.save_room_link( - self.db_path, - hosted_room_links.make_stored_link( - room_id=room_id, - member_id=member_id, - target_url=target_url, - target_profile=route.target_profile, - grant=route.grant, - catalog=catalog, - cancellation_scope_id=route.cancellation_scope_id, - trace_id=route.trace_id, - ), - ) # Persistence is the publication boundary. A failed disk write must - # never leave a process-local route that disappears after restart. + # never leave a process-local route that disappears after restart. The + # same lock makes reconnect a compare-and-swap against grant rotation. with self._policy_lock: + current = self.peer_routes.get((room_id, member_id)) + current_sha256 = ( + hashlib.sha256(current.grant.encode("utf-8")).hexdigest() + if current is not None + else "" + ) + incoming_sha256 = hashlib.sha256(route.grant.encode("utf-8")).hexdigest() + if ( + current_sha256 != incoming_sha256 + and expected_grant_sha256 is not None + and current_sha256 != expected_grant_sha256 + ): + raise RuntimeError("peer route changed during reconnect") + if target_url is not None and catalog is not None: + hosted_room_links.save_room_link( + self.db_path, + hosted_room_links.make_stored_link( + room_id=room_id, + member_id=member_id, + target_url=target_url, + target_profile=route.target_profile, + grant=route.grant, + catalog=catalog, + cancellation_scope_id=route.cancellation_scope_id, + trace_id=route.trace_id, + ), + ) self.peer_routes[(room_id, member_id)] = route self.peer_clients[(room_id, member_id)] = client self._peer_route_status[(room_id, member_id)] = "ready" @@ -421,62 +436,62 @@ def _rotate_route_grant( catalog: GatewayRoomCatalog | None = None, ) -> None: """Persist a target-refreshed scoped grant before publishing it live.""" - key = (room_id, member_id) - route = self.peer_routes.get(key) - if route is None: - raise RuntimeError("peer room route is unavailable") - stored = next( - ( - link - for link in hosted_room_links.load_room_links(self.db_path) - if (link.room_id, link.member_id) == key - ), - None, - ) - if stored is None: - raise RuntimeError("peer room route cannot be renewed before persistence") - effective_catalog = catalog or stored.catalog - if catalog is not None and ( - catalog.installation_id != route.target_install_id - or catalog.execution_policy.target_profile != route.target_profile - or PROTOCOL_VERSION not in catalog.protocol_versions - or "direct" not in catalog.link_modes - or not catalog.text - or catalog.execution_policy.policy_digest - != route.execution_policy_digest - ): - self._set_route_status(room_id, member_id, "needs_reauthorization") - raise RuntimeError( - "peer room execution policy changed; reauthorization is required" + with self._policy_lock: + key = (room_id, member_id) + route = self.peer_routes.get(key) + if route is None: + raise RuntimeError("peer room route is unavailable") + stored = next( + ( + link + for link in hosted_room_links.load_room_links(self.db_path) + if (link.room_id, link.member_id) == key + ), + None, ) - rotated_route = replace( - route, - grant=grant, - capability_digest=( - catalog.catalog_digest - if catalog is not None - else route.capability_digest - ), - execution_policy_digest=( - catalog.execution_policy.policy_digest - if catalog is not None - else route.execution_policy_digest - ), - ) - hosted_room_links.save_room_link( - self.db_path, - hosted_room_links.make_stored_link( - room_id=room_id, - member_id=member_id, - target_url=stored.target_url, - target_profile=stored.target_profile, + if stored is None: + raise RuntimeError("peer room route cannot be renewed before persistence") + effective_catalog = catalog or stored.catalog + if catalog is not None and ( + catalog.installation_id != route.target_install_id + or catalog.execution_policy.target_profile != route.target_profile + or PROTOCOL_VERSION not in catalog.protocol_versions + or "direct" not in catalog.link_modes + or not catalog.text + or catalog.execution_policy.policy_digest + != route.execution_policy_digest + ): + self._set_route_status(room_id, member_id, "needs_reauthorization") + raise RuntimeError( + "peer room execution policy changed; reauthorization is required" + ) + rotated_route = replace( + route, grant=grant, - catalog=effective_catalog, - cancellation_scope_id=stored.cancellation_scope_id, - trace_id=stored.trace_id, - ), - ) - with self._policy_lock: + capability_digest=( + catalog.catalog_digest + if catalog is not None + else route.capability_digest + ), + execution_policy_digest=( + catalog.execution_policy.policy_digest + if catalog is not None + else route.execution_policy_digest + ), + ) + hosted_room_links.save_room_link( + self.db_path, + hosted_room_links.make_stored_link( + room_id=room_id, + member_id=member_id, + target_url=stored.target_url, + target_profile=stored.target_profile, + grant=grant, + catalog=effective_catalog, + cancellation_scope_id=stored.cancellation_scope_id, + trace_id=stored.trace_id, + ), + ) self.peer_routes[key] = rotated_route self._peer_route_status[key] = "ready" @@ -493,6 +508,33 @@ def _route_statuses(self, room_id: str | None = None) -> list[dict[str, str]]: ] return sorted(rows, key=lambda row: (row["room_id"], row["member_id"])) + def status_with_grant_fingerprints(self, room_id: str) -> dict[str, Any]: + """Snapshot reconnect status and non-secret grant identity atomically.""" + with self._policy_lock: + status = self.status(room_id) + return { + **status, + "peer_routes": [ + { + **row, + **( + { + "grant_sha256": hashlib.sha256( + route.grant.encode("utf-8") + ).hexdigest() + } + if ( + route := self.peer_routes.get( + (room_id, str(row.get("member_id") or "")) + ) + ) + else {} + ), + } + for row in status.get("peer_routes", []) + ], + } + def _events(self, room_id: str) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] cursor = 0 diff --git a/tui_gateway/methods_groups.py b/tui_gateway/methods_groups.py index e60831db47e77..0d05bd8c3c4bf 100644 --- a/tui_gateway/methods_groups.py +++ b/tui_gateway/methods_groups.py @@ -27,6 +27,7 @@ "groups.retry", "groups.approve", "groups.peer.invite", + "groups.peer.revoke_exact", "groups.peer.revoke", "groups.peer.register", }) @@ -255,6 +256,7 @@ def _(rid, params: dict) -> dict: "replayable_disband", "typed_events", "actor_identity", + "peer_route_grant_fingerprint", ], "methods": [ "groups.capabilities", @@ -270,6 +272,7 @@ def _(rid, params: dict) -> dict: "groups.retry", "groups.approve", "groups.peer.invite", + "groups.peer.revoke_exact", "groups.peer.revoke", "groups.peer.register", ], @@ -355,6 +358,7 @@ def _(rid, params: dict) -> dict: gateway_room_grant_secret(), str(params.get("grant") or ""), permission="status", + allow_expired_for_revocation=True, ) if ( claims["target_profile"] != profile @@ -374,6 +378,38 @@ def _(rid, params: dict) -> dict: return _err(rid, 4122, str(exc)) +@method("groups.peer.revoke_exact") +def _(rid, params: dict) -> dict: + """Revoke only this bearer grant, preserving concurrent replacements.""" + try: + from gateway import hosted_rooms + from gateway.hosted_room_peer import decode_room_grant, gateway_room_grant_secret + + profile = _requested_profile(params) + claims = decode_room_grant( + gateway_room_grant_secret(), + str(params.get("grant") or ""), + permission="status", + allow_expired_for_revocation=True, + ) + if ( + claims["target_profile"] != profile + or claims["target_install_id"] + != hosted_rooms.local_authority_gateway_id() + ): + raise ValueError("room grant target does not match this profile") + hosted_rooms.revoke_room_grant_id( + hosted_rooms.default_db_path(), + claims=claims, + expires_at=float( + claims.get("status_expires_at", claims["expires_at"]) + ), + ) + return _ok(rid, {"revoked": True}) + except Exception as exc: + return _err(rid, 4122, str(exc)) + + @method("groups.peer.register") def _(rid, params: dict) -> dict: """Register and probe one scoped target route on the room home.""" @@ -402,6 +438,14 @@ def _(rid, params: dict) -> dict: raise ValueError("target does not support a direct RoomLink") target_profile = str(params.get("target_profile") or "") grant = str(params.get("grant") or "") + expected_grant_sha256 = None + if "expected_grant_sha256" in params: + expected_grant_sha256 = str(params.get("expected_grant_sha256") or "") + if expected_grant_sha256 and ( + len(expected_grant_sha256) != 64 + or any(character not in "0123456789abcdef" for character in expected_grant_sha256) + ): + raise ValueError("expected_grant_sha256 must be a sha256 digest") client = PeerRunsHTTPClient( base_url=target_url, api_key="", @@ -452,6 +496,11 @@ def _(rid, params: dict) -> dict: client=client, target_url=target_url, catalog=catalog, + **( + {"expected_grant_sha256": expected_grant_sha256} + if expected_grant_sha256 is not None + else {} + ), ) return _ok( rid, @@ -535,15 +584,16 @@ def _(rid, params: dict) -> dict: include_disbanded=params.get("include_disbanded") is True, ) service = get_hosted_room_service() + driver_status = ( + service.status_with_grant_fingerprints(str(room["room_id"])) + if service is not None and room.get("disbanded_at") is None + else None + ) return _ok( rid, { "room": room, - **( - {"driver_status": service.status(str(room["room_id"]))} - if service is not None and room.get("disbanded_at") is None - else {} - ), + **({"driver_status": driver_status} if driver_status else {}), }, ) except HostedRoomError as exc: From 2e2ecc074b4c37f22408cc6f29071e6bb251e314 Mon Sep 17 00:00:00 2001 From: "Axl Ibiza, MBA" Date: Tue, 1 Sep 2026 09:23:28 -0500 Subject: [PATCH 05/16] feat(bot-mode): bring Group Chats to mobile gateways Give Bot Group Chats one durable gateway control surface across Telegram, Discord, Matrix, Signal, WhatsApp, Slack, and other messaging clients. Mobile commands append to the room log or Desktop mailbox without interrupting the ordinary agent session, preserve exact authority/idempotency/stop fences, and suppress standalone lease-wait refresh floods on adapters that cannot update status in place. --- .../src/plugins/hermes-bots/create-dialog.tsx | 259 ++- .../desktop-room-command-client.test.ts | 253 +++ .../desktop-room-command-client.ts | 268 +++ .../desktop-room-command-runtime.test.ts | 418 ++++ .../desktop-room-command-runtime.ts | 798 +++++++ .../plugins/hermes-bots/group-chat-parts.tsx | 1 + .../hermes-bots/group-chat-view.test.ts | 132 +- .../plugins/hermes-bots/group-chat-view.tsx | 216 +- .../plugins/hermes-bots/group-chat.test.ts | 72 + .../src/plugins/hermes-bots/group-chat.ts | 401 +++- .../group-continuity-creation.test.tsx | 425 ++++ .../group-membership-controls.test.tsx | 98 + .../plugins/hermes-bots/group-membership.ts | 21 +- .../src/plugins/hermes-bots/group-rounds.ts | 295 ++- .../hermes-bots/hosted-room-cleanup.test.ts | 542 +++++ .../hermes-bots/hosted-room-cleanup.ts | 602 +++++ .../hermes-bots/hosted-room-client.test.ts | 525 +++++ .../plugins/hermes-bots/hosted-room-client.ts | 1243 +++++++++++ .../hosted-room-reauthorization.test.ts | 631 ++++++ .../hosted-room-reauthorization.ts | 305 +++ .../hosted-room-reconnect-runtime.test.ts | 602 +++++ .../hermes-bots/hosted-room-runtime.test.ts | 1752 +++++++++++++++ .../hermes-bots/hosted-room-runtime.ts | 1866 ++++++++++++++++ .../src/plugins/hermes-bots/i18n.test.ts | 39 + apps/desktop/src/plugins/hermes-bots/i18n.ts | 270 ++- .../plugins/hermes-bots/plugin-panes.test.tsx | 44 +- .../src/plugins/hermes-bots/plugin.tsx | 65 +- .../src/plugins/hermes-bots/roster-pane.tsx | 10 +- .../plugins/hermes-bots/row-helpers.test.ts | 17 + .../src/plugins/hermes-bots/row-helpers.ts | 7 + apps/desktop/src/plugins/hermes-bots/types.ts | 35 + docs/relay-connector-contract.md | 10 +- gateway/authz_mixin.py | 122 +- gateway/desktop_room_mailbox.py | 1097 +++++++++ gateway/hosted_room_contract.py | 385 ++++ gateway/hosted_room_control_client.py | 170 ++ gateway/hosted_room_controls.py | 1325 +++++++++++ gateway/hosted_room_driver.py | 237 +- gateway/hosted_room_messaging.py | 1957 +++++++++++++++++ gateway/hosted_room_peer.py | 9 +- gateway/hosted_room_policy_checkpoint.py | 29 +- gateway/hosted_room_replicas.py | 744 ++++--- gateway/hosted_room_storage.py | 1461 ++++++++++++ gateway/hosted_rooms.py | 1505 +------------ gateway/platforms/api_server_room_controls.py | 368 ++++ gateway/platforms/api_server_room_grants.py | 25 +- gateway/platforms/signal.py | 3 + gateway/platforms/whatsapp_common.py | 1 - gateway/relay/ws_transport.py | 23 + gateway/run.py | 54 + gateway/session.py | 6 + gateway/slash_access.py | 52 + gateway/slash_commands.py | 552 ++++- hermes_cli/commands.py | 6 +- plugins/platforms/discord/adapter.py | 37 +- plugins/platforms/matrix/adapter.py | 8 + plugins/platforms/mattermost/adapter.py | 1 + plugins/platforms/slack/adapter.py | 10 +- plugins/platforms/telegram/adapter.py | 25 +- plugins/platforms/whatsapp/adapter.py | 12 +- run_agent.py | 15 +- .../test_api_server_room_controls.py | 248 +++ tests/gateway/test_api_server_room_grants.py | 62 + .../test_api_server_runs_extraction.py | 6 +- .../test_command_bypass_active_session.py | 3 +- tests/gateway/test_desktop_room_mailbox.py | 823 +++++++ tests/gateway/test_discord_component_auth.py | 66 +- tests/gateway/test_discord_slash_commands.py | 41 +- .../gateway/test_group_chat_matrix_adapter.py | 84 + .../gateway/test_group_chat_slack_adapter.py | 112 + .../test_hosted_room_control_client.py | 161 ++ tests/gateway/test_hosted_room_controls.py | 637 ++++++ tests/gateway/test_hosted_room_driver.py | 181 ++ .../test_hosted_room_exact_grant_revoke.py | 51 + tests/gateway/test_hosted_room_messaging.py | 1557 +++++++++++++ .../test_hosted_room_messaging_security.py | 969 ++++++++ tests/gateway/test_hosted_room_peer.py | 63 +- tests/gateway/test_hosted_room_replicas.py | 897 ++++++-- tests/gateway/test_hosted_rooms.py | 74 +- .../test_session_lease_wait_refresh.py | 107 + tests/gateway/test_signal.py | 26 + tests/gateway/test_signal_format.py | 2 - tests/gateway/test_slash_access.py | 93 +- tests/gateway/test_telegram_choice_picker.py | 143 ++ tests/gateway/test_telegram_format.py | 1 - tests/gateway/test_telegram_reply_quote.py | 13 + tests/gateway/test_whatsapp_formatting.py | 9 +- tests/gateway/test_whatsapp_from_owner.py | 3 +- tests/tui_gateway/test_change_watcher.py | 11 + tests/tui_gateway/test_groups_methods.py | 236 +- .../test_groups_replication_methods.py | 175 +- .../test_hosted_room_grant_fingerprint.py | 116 + .../test_hosted_room_messaging_retry.py | 325 +++ tests/tui_gateway/test_hosted_room_service.py | 45 +- tui_gateway/hosted_room_driver.py | 22 +- tui_gateway/hosted_room_service.py | 298 ++- tui_gateway/methods_groups.py | 437 +++- tui_gateway/server.py | 16 + website/docs/reference/slash-commands.md | 3 +- website/docs/user-guide/bot-mode.md | 36 + 100 files changed, 28201 insertions(+), 2412 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/group-membership-controls.test.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-reconnect-runtime.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts create mode 100644 gateway/desktop_room_mailbox.py create mode 100644 gateway/hosted_room_contract.py create mode 100644 gateway/hosted_room_control_client.py create mode 100644 gateway/hosted_room_controls.py create mode 100644 gateway/hosted_room_messaging.py create mode 100644 gateway/hosted_room_storage.py create mode 100644 gateway/platforms/api_server_room_controls.py create mode 100644 tests/gateway/platforms/test_api_server_room_controls.py create mode 100644 tests/gateway/test_desktop_room_mailbox.py create mode 100644 tests/gateway/test_group_chat_matrix_adapter.py create mode 100644 tests/gateway/test_group_chat_slack_adapter.py create mode 100644 tests/gateway/test_hosted_room_control_client.py create mode 100644 tests/gateway/test_hosted_room_controls.py create mode 100644 tests/gateway/test_hosted_room_exact_grant_revoke.py create mode 100644 tests/gateway/test_hosted_room_messaging.py create mode 100644 tests/gateway/test_hosted_room_messaging_security.py create mode 100644 tests/gateway/test_session_lease_wait_refresh.py create mode 100644 tests/gateway/test_telegram_choice_picker.py create mode 100644 tests/tui_gateway/test_hosted_room_grant_fingerprint.py create mode 100644 tests/tui_gateway/test_hosted_room_messaging_retry.py diff --git a/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx b/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx index 79c339d749be8..52c460ba48b40 100644 --- a/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx +++ b/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx @@ -44,8 +44,16 @@ import { AvatarPicker } from './avatar-picker' import { $selectedBot } from './bot-state' import { createCanonicalChat } from './canonical-chat' import { $botMeta, botHandle, botRosterKey, filterBots, ROSTER_KEY, saveBotMeta } from './data' +import { prepareDesktopRoomAuthority } from './desktop-room-command-runtime' import { labeled, ResizableFrame } from './dialog-parts' -import { GROUP_CHAT_MAX_MEMBERS, mintGroupRoomId, uniqueGroupChatName, updateGroupChat } from './group-chat' +import { + $groupChats, + GROUP_CHAT_MAX_MEMBERS, + groupChatHostedGateway, + mintGroupRoomId, + uniqueGroupChatName, + updateGroupChat +} from './group-chat' import type { GroupChatRoom } from './group-chat' import { GroupImageControls } from './group-chat-parts' import { @@ -55,6 +63,13 @@ import { knownGroups, liveGroupChatNames } from './group-membership' +import { + createAutonomousHostedGroupChat, + describeHostedRoomCreationError, + markHostedRoomLocallyDeleted, + probeHostedRoomMembers +} from './hosted-room-runtime' +import type { HostedRoomProbe } from './hosted-room-runtime' import { useBots } from './i18n' import { displayName, slugify } from './labels' import { McpSetupButton } from './mcp-setup' @@ -1026,11 +1041,23 @@ interface GroupDialogProps { export function GroupDialog({ bot, onClose }: GroupDialogProps) { const b = useBots() const meta = useValue($botMeta) + const rooms = useValue($groupChats) const [name, setName] = useState('') const current = botGroups(botRosterMeta(bot, meta)) const groups = knownGroups(meta) + const hostedCurrent = current.filter(group => groupChatHostedGateway(rooms[group])) + const removableCurrent = current.filter(group => !groupChatHostedGateway(rooms[group])) const setMembership = (group: string, enabled: boolean) => { + if (groupChatHostedGateway(rooms[group])) { + host.notify({ + kind: 'info', + message: b.group.hostedMembersFixed + }) + + return + } + void saveBotMeta(bot, groupMembershipPatch(botRosterMeta(bot, meta), group, enabled)) host.notify({ kind: 'info', @@ -1058,13 +1085,21 @@ export function GroupDialog({ bot, onClose }: GroupDialogProps) {
{groups.map(group => { const enabled = current.includes(group) + const hosted = Boolean(groupChatHostedGateway(rooms[group])) return ( ) @@ -1093,19 +1128,19 @@ export function GroupDialog({ bot, onClose }: GroupDialogProps) { Create & join - {current.length ? ( + {removableCurrent.length ? ( ) : null} @@ -1132,6 +1167,9 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea const [checked, setChecked] = useState>({}) const [name, setName] = useState('') const [image, setImage] = useState(null) + const [hostProbe, setHostProbe] = useState(null) + const [createPending, setCreatePending] = useState(false) + const [createError, setCreateError] = useState('') // Reset per open so a cancelled draft doesn't leak into the next one. useEffect(() => { @@ -1140,6 +1178,9 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea setChecked({}) setName('') setImage(null) + setHostProbe(null) + setCreatePending(false) + setCreateError('') } }, [open]) @@ -1155,8 +1196,49 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea : b.group.nameLabel const canCreate = selected.length >= 2 && Boolean(name.trim() || selected.length) + const selectedRouteKey = selected.map(botRosterKey).sort().join('|') + const resolvedProbe = hostProbe?.key === selectedRouteKey ? hostProbe.probe : null + const hostProbePending = selected.length >= 2 && hostProbe?.key !== selectedRouteKey + + useEffect(() => { + let cancelled = false + + if (!open || selected.length < 2) { + setHostProbe(null) + + return () => { + cancelled = true + } + } + + void probeHostedRoomMembers(durableGroupChatMembers(selected)) + .then(probe => { + if (cancelled) { + return + } + + setHostProbe({ + key: selectedRouteKey, + probe + }) + }) + .catch(() => { + if (!cancelled) { + setHostProbe({ + key: selectedRouteKey, + probe: null + }) + } + }) - const create = () => { + return () => { + cancelled = true + } + // Stable member ownership, not object identity, is the probe boundary. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, selectedRouteKey]) + + const create = async () => { const base = (name.trim() || placeholder).slice(0, 64) if (selected.length < 2 || !base) { @@ -1180,32 +1262,134 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea } const groupName = uniqueGroupChatName(base, taken) - const roomId = mintGroupRoomId() + let roomId = mintGroupRoomId() - for (const bot of selected) { - void saveBotMeta(bot, groupMembershipPatch(botRosterMeta(bot, allMeta), groupName, true)) - } + setCreatePending(true) + setCreateError('') + + try { + if (hostProbePending) { + return + } + + // RPCs below can refresh or switch the live roster before they settle. + // Capture immutable member ownership now so the local projection matches + // the exact source routes used to create the hosted room. + const roomMembers = durableGroupChatMembers(selected) + + const metadataOwners = selected.map(bot => ({ + ...bot, + ...(bot.route + ? { + route: { ...bot.route } + } + : {}) + })) + + const autonomousMembers = roomMembers.map((member, index) => { + const bot = selected[index] + const label = displayName(bot, botRosterMeta(bot, allMeta)) + + return { + member, + profile: member.targetProfile || member.name, + handle: botHandle(member.name, member), + ...(label + ? { + displayName: label + } + : {}) + } + }) + + const hostName = selected[0]?.connectionLabel || b.group.thisHost + let hosted: Awaited> | null = null + + if (resolvedProbe?.eligible) { + try { + hosted = await createAutonomousHostedGroupChat({ + probe: resolvedProbe, + roomId, + name: groupName, + members: autonomousMembers + }) + } catch (error) { + if ((error as { fallbackSafe?: boolean })?.fallbackSafe === false) { + setCreateError(describeHostedRoomCreationError(error) || b.group.createFailed) + + return + } - // Persist every machine identity, including today's active source. That - // member becomes remote after a source switch and cannot rely on the new - // gateway's name-keyed bot metadata to remain seated in this room. - const roomMembers = durableGroupChatMembers(selected) - updateGroupChat(groupName, (room: GroupChatRoom) => { - room.members = roomMembers - room.roomId = roomId + const retiredRoomId = roomId - if (image) { - room.image = image + markHostedRoomLocallyDeleted(retiredRoomId) + + const rooms = $groupChats.get() + const withoutRetiredProjection = Object.fromEntries( + Object.entries(rooms).filter(([, room]) => room.roomId !== retiredRoomId) + ) + + if (Object.keys(withoutRetiredProjection).length !== Object.keys(rooms).length) { + $groupChats.set(withoutRetiredProjection) + } + + roomId = mintGroupRoomId() + + host.notify({ + kind: 'info', + message: b.group.hostedFallbackToDesktop(hostName) + }) + } } - return room - }) - host.notify({ - kind: 'info', - message: `“${groupName}” created with ${selected.length} bots` - }) - onClose() - onCreated?.(groupName) + const desktopAuthority = hosted ? null : await prepareDesktopRoomAuthority() + + for (const owner of metadataOwners) { + await saveBotMeta(owner, groupMembershipPatch(botRosterMeta(owner, allMeta), groupName, true)) + } + + // Persist every machine identity, including today's active source. That + // member becomes remote after a source switch and cannot rely on the new + // gateway's name-keyed bot metadata to remain seated in this room. + updateGroupChat(groupName, (room: GroupChatRoom) => { + room.members = roomMembers + room.roomId = roomId + room.continuityMode = hosted?.continuityMode || 'desktop' + + if (desktopAuthority) { + room.desktopAuthorityHash = desktopAuthority.desktopAuthorityHash + room.desktopAuthorityToken = desktopAuthority.desktopAuthorityToken + room.desktopCoordinatorId = desktopAuthority.desktopCoordinatorId + } + + if (hosted) { + room.hosted = hosted.authorityId + room.hostedEpoch = hosted.authorityEpoch + room.hostedConnectionId = hosted.connectionId + room.hostedSeq = 0 + room.hostedStatus = { + state: 'ready', + label: b.roster.ready + } + } + + if (image) { + room.image = image + } + + return room + }) + host.notify({ + kind: 'info', + message: b.group.created(groupName, selected.length) + }) + onClose() + onCreated?.(groupName) + } catch { + setCreateError(b.group.createFailed) + } finally { + setCreatePending(false) + } } return ( @@ -1220,7 +1404,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea {b.group.newTitle} - {`Pick 2–${GROUP_CHAT_MAX_MEMBERS} bots. Local memberships sync through each Bot profile; cross-machine members stay scoped to this room.`} + {b.group.newDesc} {/* TODO(bot-mode-types): this search box never takes focus when the dialog opens — SearchField accepts no `autoFocus` prop and forwards no extra @@ -1243,13 +1427,13 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea variant="muted" > setChecked(prev => ({ ...prev, [botRosterKey(bot)]: false })) } - title={b.group.removeFromSelection} > {displayName(bot, botRosterMeta(bot, allMeta))} @@ -1309,7 +1493,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea }) ) : (
- {query.trim() ? `No bots match “${query.trim()}”` : 'No bots yet — create one first.'} + {query.trim() ? b.roster.noMatchQuery(query.trim()) : b.group.noBots}
)}
@@ -1324,7 +1508,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
{ event.preventDefault() - create() + void create() }} >
+ {createError ?
{createError}
: null} - + diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts new file mode 100644 index 0000000000000..c0474ae33c6cc --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + desktopRoomDescriptors, + desktopRoomIdentity, + runDesktopRoomCommandCycle +} from './desktop-room-command-client' +import type { GroupChat, ProfileRoute } from './types' + +const route = (connectionId: string): ProfileRoute => ({ + connectionId, + mode: 'remote', + profile: 'default', + targetProfile: 'default' +}) + +const classic = (overrides: Partial = {}): GroupChat => ({ + desktopAuthorityToken: 'authority:test', + log: [], + roomId: 'room-1', + watermarks: {}, + ...overrides +}) + +describe('classic Group Chat command client', () => { + it('advertises only classic rooms with local authority tokens', () => { + const rooms = { + Classic: classic(), + Legacy: classic({ roomId: null }), + Hosted: classic({ hosted: 'gateway-a' }), + Deleted: classic({ tombstone: true }) + } + + expect(desktopRoomIdentity('Legacy', rooms.Legacy)).toBe('name:Legacy') + expect(desktopRoomDescriptors(rooms)).toEqual([ + { + authorityToken: 'authority:test', + name: 'Classic', + roomId: 'room-1' + }, + { + authorityToken: 'authority:test', + name: 'Legacy', + roomId: 'name:Legacy' + } + ]) + }) + + it('claims, executes, and completes once per gateway', async () => { + const calls: Array<{ connectionId: string; method: string; params: Record }> = [] + + const request = vi.fn(async (target: ProfileRoute, method: string, params: Record) => { + calls.push({ + connectionId: target.connectionId, + method, + params + }) + + if (target.connectionId === 'old') { + throw new Error('method not found') + } + + return method === 'groups.desktop.claim' + ? { + commands: [ + { + action: 'send', + command_id: 'messaging:1', + payload: { message: 'hello' }, + room_id: 'room-1' + } + ] + } + : {} + }) + + const outcomes = await runDesktopRoomCommandCycle({ + consumerId: 'desktop:test', + execute: async command => ({ + thread_id: `thread:${command.command_id}` + }), + request, + rooms: { + Classic: classic() + }, + routes: [route('old'), route('current'), route('current')] + }) + + expect(outcomes).toEqual([ + { + commandId: 'messaging:1', + connectionId: 'current', + success: true + } + ]) + expect(calls.filter(call => call.method === 'groups.desktop.claim').map(call => call.connectionId)).toEqual([ + 'old', + 'current' + ]) + expect(calls.find(call => call.method === 'groups.desktop.complete')?.params).toMatchObject({ + result: { + thread_id: 'thread:messaging:1' + }, + success: true + }) + }) + + it('reads attachments through the gateway that issued the claim', async () => { + const calls: string[] = [] + + await runDesktopRoomCommandCycle({ + consumerId: 'desktop:test', + execute: async (_command, _rooms, context) => { + await context.request('groups.attachment.read', { + attachment_id: 'att_1' + }) + + return { + settled: true + } + }, + request: async (target, method) => { + calls.push(`${target.connectionId}:${method}`) + + return method === 'groups.desktop.claim' + ? { + commands: [ + { + action: 'send', + command_id: 'messaging:file', + lease_token: 'lease:one', + room_id: 'room-1' + } + ] + } + : {} + }, + rooms: { + Classic: classic() + }, + routes: [route('gateway-b')] + }) + + expect(calls).toContain('gateway-b:groups.attachment.read') + }) + + it('leaves retryable work unacknowledged', async () => { + const methods: string[] = [] + + const outcomes = await runDesktopRoomCommandCycle({ + consumerId: 'desktop:test', + execute: async () => { + throw Object.assign(new Error('member offline'), { + retryable: true + }) + }, + request: async (_target, method) => { + methods.push(method) + + return method === 'groups.desktop.claim' + ? { + commands: [ + { + command_id: 'messaging:later', + room_id: 'room-1' + } + ] + } + : {} + }, + rooms: { + Classic: classic() + }, + routes: [route('current')] + }) + + expect(methods).toEqual(['groups.desktop.claim']) + expect(outcomes).toEqual([ + { + commandId: 'messaging:later', + connectionId: 'current', + retryable: true, + success: false + } + ]) + }) + + it('bounds large room claims', async () => { + const claimSizes: number[] = [] + + const rooms = Object.fromEntries( + Array.from({ length: 260 }, (_, index) => [ + `Room ${index}`, + classic({ + desktopAuthorityToken: `authority:${index}`, + roomId: `room-${index}` + }) + ]) + ) + + await runDesktopRoomCommandCycle({ + consumerId: 'desktop:test', + execute: async () => ({}), + request: async (_target, method, params) => { + if (method === 'groups.desktop.claim') { + claimSizes.push((params.room_authorities as unknown[]).length) + } + + return { + commands: [] + } + }, + rooms, + routes: [route('current')] + }) + + expect(claimSizes).toEqual([128, 128, 4]) + }) + + it('keeps the unscoped local gateway compatibility path', async () => { + const calls: Array<{ method: string; params: Record }> = [] + + await runDesktopRoomCommandCycle({ + consumerId: 'desktop:test', + execute: async () => ({}), + request: async (_target, method, params) => { + calls.push({ method, params }) + + return { + commands: [] + } + }, + rooms: { + Local: classic({ + roomId: 'room-local' + }) + }, + routes: [route('')] + }) + + expect(calls[0]).toMatchObject({ + method: 'groups.desktop.claim', + params: { + room_authorities: [ + { + authority_token: 'authority:test', + room_id: 'room-local' + } + ] + } + }) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts new file mode 100644 index 0000000000000..6ffede5313ebe --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts @@ -0,0 +1,268 @@ +import type { GroupChat, ProfileRoute } from './types' + +const MAX_COMMANDS_PER_CLAIM = 8 +const MAX_COMMANDS_PER_WAKE = 64 +const MAX_ROOM_IDS_PER_CLAIM = 128 +const LEASE_RENEW_INTERVAL_MS = 15_000 + +export interface DesktopRoomDescriptor { + authorityToken: string + name: string + roomId: string +} + +export interface DesktopRoomCommand { + action?: string + command_id?: string + lease_token?: string + payload?: Record + room_id?: string + target_command_state?: string + target_result_code?: string +} + +interface CommandExecutionContext { + consumerId: string + request: (method: string, params: Record) => Promise + route: ProfileRoute + signal: AbortSignal | null +} + +interface RunDesktopRoomCommandCycleInput { + actions?: string[] | null + consumerId: string + execute: ( + command: DesktopRoomCommand, + rooms: DesktopRoomDescriptor[], + context: CommandExecutionContext + ) => Promise + request: (route: ProfileRoute, method: string, params: Record) => Promise + rooms: Record + routes: ProfileRoute[] + shouldContinue?: () => boolean +} + +export interface DesktopRoomCommandOutcome { + commandId: string + connectionId: string + leaseLost?: boolean + retryable?: boolean + success: boolean +} + +/** Stable identity shared by the bounded gateway projection and command queue. */ +export function desktopRoomIdentity(name: string, room: GroupChat) { + const roomId = String(room?.roomId || '').trim() + + return roomId || `name:${String(name || '').trim()}` +} + +/** Classic rooms this Desktop can coordinate. Hosted rooms run on a gateway. */ +export function desktopRoomDescriptors(rooms: Record): DesktopRoomDescriptor[] { + return Object.entries(rooms || {}) + .filter(([, room]) => { + const hosted = typeof room?.hosted === 'string' ? room.hosted.trim() : '' + + return !hosted && !room?.tombstone && Array.isArray(room?.log) + }) + .map(([name, room]) => ({ + name, + roomId: desktopRoomIdentity(name, room), + authorityToken: String(room?.desktopAuthorityToken || '').trim() + })) + .filter(room => room.roomId && room.name && room.authorityToken) +} + +export function createDesktopRoomConsumerId() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { + return `desktop:${globalThis.crypto.randomUUID()}` + } + + return `desktop:${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + +function boundedError(error: unknown) { + const text = String(error instanceof Error ? error.message : 'Desktop could not apply the Group Chat command') + .replace(/\s+/g, ' ') + .trim() + + return text.slice(0, 240) +} + +/** Claim and apply classic-room commands from every reachable default gateway. + * Missing methods identify an older backend and leave its queue untouched. */ +export async function runDesktopRoomCommandCycle({ + routes, + consumerId, + rooms, + request, + execute, + actions = null, + shouldContinue = () => true +}: RunDesktopRoomCommandCycleInput): Promise { + const descriptors = desktopRoomDescriptors(rooms) + + if (!descriptors.length) { + return [] + } + + const roomBatches: DesktopRoomDescriptor[][] = [] + + for (let index = 0; index < descriptors.length; index += MAX_ROOM_IDS_PER_CLAIM) { + roomBatches.push(descriptors.slice(index, index + MAX_ROOM_IDS_PER_CLAIM)) + } + + const seenConnections = new Set() + const outcomes: DesktopRoomCommandOutcome[] = [] + + for (const route of Array.isArray(routes) ? routes : []) { + const connectionId = String(route?.connectionId || '') + const routeKey = connectionId || '__active__' + + if (seenConnections.has(routeKey)) { + continue + } + + seenConnections.add(routeKey) + let remaining = MAX_COMMANDS_PER_WAKE + + for (const batch of roomBatches) { + if (remaining <= 0) { + return outcomes + } + + const roomAuthorities = batch.map(room => ({ + room_id: room.roomId, + authority_token: room.authorityToken + })) + + while (remaining > 0) { + const claimLimit = Math.min(MAX_COMMANDS_PER_CLAIM, remaining) + let claimed: unknown + + try { + claimed = await request(route, 'groups.desktop.claim', { + consumer_id: consumerId, + room_authorities: roomAuthorities, + ...(Array.isArray(actions) && actions.length + ? { + actions + } + : {}), + limit: claimLimit + }) + } catch { + break + } + + const commands = Array.isArray((claimed as { commands?: unknown[] } | null)?.commands) + ? ((claimed as { commands: DesktopRoomCommand[] }).commands || []) + : [] + + remaining -= commands.length + + for (const command of commands) { + if (!shouldContinue()) { + return outcomes + } + + let success = false + let result: unknown + let renewTimer: ReturnType | null = null + let leaseLost = false + const abortController = typeof AbortController === 'function' ? new AbortController() : null + const leaseToken = String(command?.lease_token || '') + + if (leaseToken && typeof setInterval === 'function') { + renewTimer = setInterval(() => { + if (!shouldContinue()) { + return + } + + void request(route, 'groups.desktop.renew', { + consumer_id: consumerId, + command_id: command.command_id, + lease_token: leaseToken + }).catch(() => { + leaseLost = true + abortController?.abort('lease-lost') + }) + }, LEASE_RENEW_INTERVAL_MS) + } + + try { + result = await execute(command, descriptors, { + signal: abortController?.signal || null, + route, + consumerId, + request: (method, params) => request(route, method, params) + }) + + if (leaseLost) { + outcomes.push({ + commandId: String(command.command_id || ''), + connectionId: routeKey, + success: false, + retryable: true, + leaseLost: true + }) + + continue + } + + success = true + } catch (error) { + if (leaseLost || (error as { retryable?: boolean } | null)?.retryable === true) { + outcomes.push({ + commandId: String(command.command_id || ''), + connectionId: routeKey, + success: false, + retryable: true, + ...(leaseLost + ? { + leaseLost: true + } + : {}) + }) + + continue + } + + result = { + message: boundedError(error) + } + } finally { + if (renewTimer !== null && typeof clearInterval === 'function') { + clearInterval(renewTimer) + } + } + + try { + await request(route, 'groups.desktop.complete', { + consumer_id: consumerId, + command_id: command.command_id, + lease_token: leaseToken, + success, + result + }) + } catch { + // The lease expires and retries the same command id. Room effects + // are idempotent, so a lost completion ACK cannot duplicate text. + } + + outcomes.push({ + commandId: String(command.command_id || ''), + connectionId: routeKey, + success + }) + } + + if (commands.length < claimLimit) { + break + } + } + } + } + + return outcomes +} diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts new file mode 100644 index 0000000000000..97f1458e4a2a8 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts @@ -0,0 +1,418 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { pluginSdkMock, scriptedStorage } from './group-test-utils' + +const { host } = vi.hoisted(() => ({ + host: {} as Record +})) + +const groupRounds = vi.hoisted(() => ({ + cancelGroupThreadForLeaseLoss: vi.fn(async (..._args: unknown[]) => undefined), + sendToGroupChat: vi.fn((..._args: unknown[]): unknown => null), + stopGroupThread: vi.fn(async (..._args: unknown[]) => undefined) +})) + +vi.mock('@hermes/plugin-sdk', async () => pluginSdkMock(host)) +vi.mock('./group-rounds', () => groupRounds) + +async function loadRuntime() { + vi.resetModules() + + for (const key of Object.keys(host)) { + delete host[key] + } + + Object.assign(host, { + activeConnectionId: () => 'gateway-a', + onEvent: vi.fn(() => () => undefined), + profileRoutes: async () => [], + request: vi.fn(async () => ({})), + requestProfile: vi.fn(async () => ({})), + retainProfileSocket: vi.fn(() => () => undefined), + state: { + connectionId: { + get: () => 'gateway-a', + listen: () => () => undefined + } + } + }) + + const [chat, data, runtime] = await Promise.all([ + import('./group-chat'), + import('./data'), + import('./desktop-room-command-runtime') + ]) + + return { + chat, + data, + runtime + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.clearAllMocks() + vi.clearAllTimers() + vi.useRealTimers() +}) + +describe('classic Group Chat command runtime', () => { + it('mints one private authority and publishes only its hash', async () => { + const loaded = await loadRuntime() + const stored = new Map() + + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + const authority = await loaded.runtime.prepareDesktopRoomAuthority() + + expect(authority.desktopCoordinatorId).toMatch(/^desktop:/) + expect(authority.desktopAuthorityToken).toMatch(/^authority:/) + expect(authority.desktopAuthorityHash).toMatch(/^[a-f0-9]{64}$/) + expect(authority.desktopAuthorityHash).not.toContain(authority.desktopAuthorityToken) + expect(stored.get('desktop-room-command-consumer-v1')).toBe(authority.desktopCoordinatorId) + + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('does not cache a coordinator identity until storage confirms it', async () => { + const loaded = await loadRuntime() + const stored = new Map() + let failRead = true + let failWrite = true + const storage = { + get: vi.fn(async (key: string) => { + if (failRead) { + throw new Error('read unavailable') + } + return stored.get(key) ?? null + }), + set: vi.fn(async (key: string, value: unknown) => { + if (failWrite) { + throw new Error('disk unavailable') + } + stored.set(key, structuredClone(value)) + }) + } + + await loaded.runtime.startDesktopRoomCommandRuntime(storage as never) + await expect(loaded.runtime.prepareDesktopRoomAuthority()).rejects.toThrow('could not read') + expect(storage.set).not.toHaveBeenCalled() + + failRead = false + await expect(loaded.runtime.prepareDesktopRoomAuthority()).rejects.toThrow('disk unavailable') + + failWrite = false + const authority = await loaded.runtime.prepareDesktopRoomAuthority() + expect(stored.get('desktop-room-command-consumer-v1')).toBe(authority.desktopCoordinatorId) + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('adopts only a classic room with local execution evidence', async () => { + const loaded = await loadRuntime() + const stored = new Map() + + loaded.chat.$groupChats.set({ + Active: { + log: [], + sessions: { + research: 'session-1' + }, + watermarks: {} + }, + Silent: { + log: [], + watermarks: {} + }, + Hosted: { + hosted: 'install:home', + log: [], + sessions: { + research: 'session-2' + }, + watermarks: {} + } + }) + + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + + expect(loaded.chat.$groupChats.get().Active).toMatchObject({ + desktopAuthorityHash: expect.stringMatching(/^[a-f0-9]{64}$/), + desktopAuthorityToken: expect.stringMatching(/^authority:/), + desktopCoordinatorId: expect.stringMatching(/^desktop:/), + roomId: expect.stringMatching(/^[a-z0-9-]+$/) + }) + expect(loaded.chat.$groupChats.get().Silent.desktopCoordinatorId).toBeUndefined() + expect(loaded.chat.$groupChats.get().Hosted.desktopCoordinatorId).toBeUndefined() + + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('lets healthy Bots continue when another Group Chat member is offline', async () => { + const loaded = await loadRuntime() + const stored = new Map() + + const members = [ + { connectionId: 'gateway-a', name: 'online' }, + { connectionId: 'gateway-b', name: 'offline', sourceMissing: true } + ] + + loaded.data.$lastRoster.set(members) + loaded.chat.$groupChats.set({ + Planning: { + log: [], + members, + roomId: 'room-1', + sessions: {}, + watermarks: {} + } + }) + groupRounds.sendToGroupChat.mockImplementation((...args: unknown[]) => { + const options = (args[5] || {}) as { entryId?: unknown } + const room = loaded.chat.$groupChats.get().Planning + loaded.chat.$groupChats.set({ + Planning: { + ...room, + desktopCommandSettled: { + [String(options.entryId)]: Date.now() + } + } + }) + + return 'thread-1' + }) + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + + const result = await loaded.runtime.executeDesktopRoomCommand( + { + action: 'send', + command_id: 'messaging:send-1', + payload: { + message: 'Review the plan', + recipients: members + }, + room_id: 'room-1' + }, + [{ authorityToken: 'authority:test', name: 'Planning', roomId: 'room-1' }], + { + consumerId: 'desktop:test', + request: vi.fn(async () => ({})), + route: { connectionId: 'gateway-a', mode: 'remote', profile: 'default', targetProfile: 'default' }, + signal: null + } + ) + + expect(result).toEqual({ room_name: 'Planning', thread_id: 'thread-1' }) + expect(groupRounds.sendToGroupChat).toHaveBeenCalledWith( + 'Planning', + expect.arrayContaining([ + expect.objectContaining({ name: 'online' }), + expect.objectContaining({ name: 'offline' }) + ]), + 'Review the plan', + null, + undefined, + expect.objectContaining({ entryId: 'messaging:send-1' }) + ) + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('settles a durable Stop after restart when its send was already superseded', async () => { + const loaded = await loadRuntime() + const stored = new Map() + const members = [{ connectionId: 'gateway-a', name: 'online' }] + + loaded.data.$lastRoster.set(members) + loaded.chat.$groupChats.set({ + Planning: { + log: [], + members, + roomId: 'room-1', + sessions: {}, + watermarks: {} + } + }) + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + + const result = await loaded.runtime.executeDesktopRoomCommand( + { + action: 'stop', + command_id: 'messaging:stop-1', + payload: { target_command_id: 'messaging:send-1' }, + room_id: 'room-1', + target_command_state: 'failed', + target_result_code: 'superseded_by_stop' + }, + [{ authorityToken: 'authority:test', name: 'Planning', roomId: 'room-1' }], + { + consumerId: 'desktop:test', + request: vi.fn(async () => ({})), + route: { connectionId: 'gateway-a', mode: 'remote', profile: 'default', targetProfile: 'default' }, + signal: null + } + ) + + expect(result).toEqual({ room_name: 'Planning', stale: true, stopped: true }) + expect(groupRounds.stopGroupThread).not.toHaveBeenCalled() + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('still aborts live work when the mailbox already marked its send superseded', async () => { + const loaded = await loadRuntime() + const stored = new Map() + const members = [{ connectionId: 'gateway-a', name: 'online' }] + + loaded.data.$lastRoster.set(members) + loaded.chat.$groupChats.set({ + Planning: { + log: [], + members, + roomId: 'room-1', + sessions: {}, + watermarks: {} + } + }) + groupRounds.sendToGroupChat.mockImplementation((...args: unknown[]) => { + const group = String(args[0] || '') + const room = loaded.chat.$groupChats.get()[group] + loaded.chat.$groupChats.set({ + [group]: { + ...room, + running: true + } + }) + + return 'thread-1' + }) + groupRounds.stopGroupThread.mockImplementation(async (...args: unknown[]) => { + const group = String(args[0] || '') + const room = loaded.chat.$groupChats.get()[group] + loaded.chat.$groupChats.set({ + [group]: { + ...room, + running: false + } + }) + }) + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + + const context = { + consumerId: 'desktop:test', + request: vi.fn(async () => ({})), + route: { connectionId: 'gateway-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + signal: null + } + + const descriptors = [{ authorityToken: 'authority:test', name: 'Planning', roomId: 'room-1' }] + + const send = loaded.runtime.executeDesktopRoomCommand( + { + action: 'send', + command_id: 'messaging:send-1', + payload: { message: 'Review the plan', recipients: members }, + room_id: 'room-1' + }, + descriptors, + context + ) + + await Promise.resolve() + await Promise.resolve() + + const stopped = await loaded.runtime.executeDesktopRoomCommand( + { + action: 'stop', + command_id: 'messaging:stop-1', + payload: { target_command_id: 'messaging:send-1' }, + room_id: 'room-1', + target_command_state: 'failed', + target_result_code: 'superseded_by_stop' + }, + descriptors, + context + ) + + expect(stopped).toEqual({ room_name: 'Planning', stopped: true }) + expect(groupRounds.stopGroupThread).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(250) + await expect(send).resolves.toEqual({ room_name: 'Planning', stopped: true }) + + groupRounds.stopGroupThread.mockClear() + loaded.chat.$groupChats.set({ + Planning: { + ...loaded.chat.$groupChats.get().Planning, + desktopCommandSettled: {}, + log: [ + { + at: 1, + from: { kind: 'user', name: 'You' }, + id: 'old-message', + text: 'Earlier work', + thread: 'thread-old' + } + ], + running: false + } + }) + groupRounds.sendToGroupChat.mockImplementation(() => { + const room = loaded.chat.$groupChats.get().Planning + loaded.chat.$groupChats.set({ Planning: { ...room, running: true } }) + return 'thread-new' + }) + groupRounds.stopGroupThread.mockResolvedValue(undefined) + + const laterSend = loaded.runtime.executeDesktopRoomCommand( + { + action: 'send', + command_id: 'messaging:send-later', + payload: { message: 'New work', recipients: members }, + room_id: 'room-1' + }, + descriptors, + context + ) + await Promise.resolve() + await Promise.resolve() + const earlierStop = await loaded.runtime.executeDesktopRoomCommand( + { + action: 'stop', + command_id: 'messaging:stop-earlier', + payload: { target_thread_id: 'thread-old' }, + room_id: 'room-1' + }, + descriptors, + context + ) + + expect(earlierStop).toEqual({ room_name: 'Planning', stopped: true }) + expect(groupRounds.stopGroupThread).toHaveBeenCalledWith('Planning', 'thread-old', expect.any(Array)) + loaded.chat.updateGroupChat('Planning', current => ({ + ...current, + desktopCommandSettled: { 'messaging:send-later': Date.now() }, + running: false + })) + await vi.advanceTimersByTimeAsync(250) + await expect(laterSend).resolves.toEqual({ room_name: 'Planning', thread_id: 'thread-new' }) + + groupRounds.cancelGroupThreadForLeaseLoss.mockClear() + const abandonedSend = loaded.runtime.executeDesktopRoomCommand( + { + action: 'send', + command_id: 'messaging:send-disposed', + payload: { message: 'Work during reload', recipients: members }, + room_id: 'room-1' + }, + descriptors, + context + ) + const abandonedExpectation = expect(abandonedSend).rejects.toThrow('moved to another Desktop') + await Promise.resolve() + await Promise.resolve() + loaded.runtime.stopDesktopRoomCommandRuntime() + await vi.advanceTimersByTimeAsync(250) + await abandonedExpectation + expect(groupRounds.cancelGroupThreadForLeaseLoss).toHaveBeenCalledWith('Planning', members) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts new file mode 100644 index 0000000000000..fa379720fbb0a --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts @@ -0,0 +1,798 @@ +import { host } from '@hermes/plugin-sdk' +import type { PluginContext } from '@hermes/plugin-sdk' + +import { $botMeta, $lastRoster, cachedUnionRoster } from './data' +import { runDesktopRoomCommandCycle } from './desktop-room-command-client' +import type { DesktopRoomCommand, DesktopRoomDescriptor } from './desktop-room-command-client' +import { + $groupChats, + boundedDesktopCommandSettled, + groupChatHostedGateway, + mintGroupRoomId, + scheduleGroupChatServerSync, + updateGroupChat +} from './group-chat' +import { groupChatBotsFromDescriptors, groupChatMemberBots } from './group-membership' +import { cancelGroupThreadForLeaseLoss, sendToGroupChat, stopGroupThread } from './group-rounds' +import type { GroupChat, GroupMember, ProfileRoute } from './types' + +const DESKTOP_ROOM_COMMAND_CONSUMER_KEY = 'desktop-room-command-consumer-v1' +const DESKTOP_ROOM_COMMAND_INTERVAL_MS = 60_000 +const DESKTOP_ROOM_COMMAND_PUSH_DEBOUNCE_MS = 250 + +let desktopRoomStorage: null | PluginContext['storage'] = null +let desktopRoomCommandConsumerId = '' +let desktopRoomCommandConsumerPromise: null | Promise = null +let desktopRoomCommandTimer: null | ReturnType = null +let desktopRoomCommandPushTimer: null | ReturnType = null +let desktopRoomCommandPushUnsub: null | (() => void) = null +let desktopRoomCommandRunning = false +let desktopRoomStopRunning = false +let desktopRoomCommandDisposed = true +let desktopRoomCommandRerun = false +let desktopRoomStopRerun = false + +const desktopRoomCommandPendingConnections = new Set() +const desktopRoomStopPendingConnections = new Set() +const desktopRoomCommandRetentions = new Map void>() + +const activeDesktopRoomCommands = new Map< + string, + { commandId: string; controller: AbortController; threadId: null | string } +>() + +function mintConsumerId() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { + return `desktop:${globalThis.crypto.randomUUID()}` + } + + return `desktop:${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + +function mintAuthorityToken() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { + return `authority:${globalThis.crypto.randomUUID()}` + } + + throw new Error('Secure Group Chat control is unavailable in this Desktop build.') +} + +async function authorityHash(token: string) { + if (!token || !globalThis.crypto?.subtle || typeof TextEncoder === 'undefined') { + return null + } + + const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)) + + return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, '0')).join('') +} + +async function ensureDesktopRoomCommandConsumerId() { + if (desktopRoomCommandConsumerId) { + return desktopRoomCommandConsumerId + } + + if (desktopRoomCommandConsumerPromise) { + return desktopRoomCommandConsumerPromise + } + + desktopRoomCommandConsumerPromise = (async () => { + if (!desktopRoomStorage?.get || !desktopRoomStorage?.set) { + throw new Error('Desktop storage is unavailable, so Group Chat control cannot be secured.') + } + + let stored = '' + + try { + stored = String((await desktopRoomStorage.get(DESKTOP_ROOM_COMMAND_CONSUMER_KEY, null)) || '').trim() + } catch (error) { + throw new Error('Desktop could not read secure Group Chat control identity.', { cause: error }) + } + + if (stored) { + desktopRoomCommandConsumerId = stored + + return stored + } + + const candidate = mintConsumerId() + + await desktopRoomStorage.set(DESKTOP_ROOM_COMMAND_CONSUMER_KEY, candidate) + const persisted = String(await desktopRoomStorage.get(DESKTOP_ROOM_COMMAND_CONSUMER_KEY, null)).trim() + if (persisted !== candidate) { + throw new Error('Desktop could not persist secure Group Chat control identity.') + } + desktopRoomCommandConsumerId = candidate + + return candidate + })().finally(() => { + desktopRoomCommandConsumerPromise = null + }) + + return desktopRoomCommandConsumerPromise +} + +export async function prepareDesktopRoomAuthority() { + const desktopCoordinatorId = await ensureDesktopRoomCommandConsumerId() + const desktopAuthorityToken = mintAuthorityToken() + const desktopAuthorityHash = await authorityHash(desktopAuthorityToken) + + if (!desktopAuthorityHash) { + throw new Error('Secure Group Chat control is unavailable in this Desktop build.') + } + + return { + desktopAuthorityHash, + desktopAuthorityToken, + desktopCoordinatorId + } +} + +function hasDesktopRoomExecutionEvidence(room: GroupChat) { + return Object.keys(room?.sessions || {}).length > 0 || Object.keys(room?.sessionOwners || {}).length > 0 +} + +async function adoptExistingDesktopRooms() { + const rooms = $groupChats.get() + + if ( + !Object.values(rooms).some( + room => + !groupChatHostedGateway(room) && + !room.tombstone && + (Boolean(room.desktopCoordinatorId) || hasDesktopRoomExecutionEvidence(room)) + ) + ) { + return + } + + const consumerId = await ensureDesktopRoomCommandConsumerId() + let changed = false + + for (const [name, room] of Object.entries(rooms)) { + if (groupChatHostedGateway(room) || room.tombstone) { + continue + } + + let coordinatorId = String(room.desktopCoordinatorId || '') + + if (!coordinatorId && hasDesktopRoomExecutionEvidence(room)) { + coordinatorId = consumerId + } + + if (coordinatorId !== consumerId) { + continue + } + + const token = String(room.desktopAuthorityToken || '') || mintAuthorityToken() + const hash = await authorityHash(token) + const roomId = String(room.roomId || '') || mintGroupRoomId() + + if (!hash) { + continue + } + + if ( + room.desktopCoordinatorId !== coordinatorId || + room.desktopAuthorityToken !== token || + room.desktopAuthorityHash !== hash || + room.roomId !== roomId + ) { + changed = true + updateGroupChat( + name, + current => ({ + ...current, + desktopAuthorityHash: hash, + desktopAuthorityToken: token, + desktopCoordinatorId: coordinatorId, + roomId + }), + { + sync: false + } + ) + } + } + + if (changed) { + scheduleGroupChatServerSync($groupChats.get()) + } +} + +function desktopRoomEntry(roomId: string, descriptors: DesktopRoomDescriptor[]) { + const descriptor = descriptors.find(room => room.roomId === roomId) + + if (!descriptor) { + return null + } + + const room = $groupChats.get()[descriptor.name] + + return room && !groupChatHostedGateway(room) ? ([descriptor.name, room] as const) : null +} + +function desktopCommandEligibleRooms(_connectionIds: string[]) { + const coordinator = String(desktopRoomCommandConsumerId || '') + + return Object.fromEntries( + Object.entries($groupChats.get()).filter(([, room]) => { + if (groupChatHostedGateway(room) || room?.tombstone) { + return false + } + + if (!coordinator || String(room?.desktopCoordinatorId || '') !== coordinator) { + return false + } + + if (!room.desktopAuthorityToken || !room.desktopAuthorityHash) { + return false + } + + return Array.isArray(room?.members) && room.members.length > 0 + }) + ) +} + +function retryableDesktopRoomCommand(message: string) { + return Object.assign(new Error(message), { + retryable: true + }) +} + +async function waitForDesktopRoomCommandSettlement(group: string, commandId: string, signal: AbortSignal | null) { + while (!desktopRoomCommandDisposed) { + if (signal?.aborted) { + throw retryableDesktopRoomCommand('The command moved to another Desktop.') + } + + const room = $groupChats.get()[group] + + if (!room) { + throw new Error('This Group Chat is no longer available.') + } + + if (room.desktopCommandSettled?.[commandId]) { + return true + } + + if (!room.running) { + return false + } + + await new Promise(resolve => setTimeout(resolve, 250)) + } + + throw retryableDesktopRoomCommand('Desktop closed before the Group Chat settled.') +} + +interface CommandExecutionContext { + consumerId: string + request: (method: string, params: Record) => Promise + route: ProfileRoute + signal: AbortSignal | null +} + +export async function executeDesktopRoomCommand( + command: DesktopRoomCommand, + descriptors: DesktopRoomDescriptor[], + { signal, request, consumerId, route }: CommandExecutionContext +) { + const assertLiveLease = () => { + if (signal?.aborted) { + throw retryableDesktopRoomCommand('The command moved to another Desktop.') + } + } + + assertLiveLease() + + const roomId = String(command.room_id || '') + const entry = desktopRoomEntry(roomId, descriptors) + + if (!entry) { + throw new Error('This Group Chat is no longer available on this Desktop.') + } + + const [group, room] = entry + const cached = cachedUnionRoster() + const roster = Array.isArray(cached?.profiles) ? cached.profiles : $lastRoster.get() + let members = groupChatMemberBots(group, roster, $botMeta.get()) + + if (command.action === 'send') { + const payload = command.payload || {} + + const frozenRecipients = + Array.isArray(payload.recipients) && payload.recipients.length + ? (payload.recipients as GroupMember[]) + : room.members || [] + + members = groupChatBotsFromDescriptors(frozenRecipients, roster) + + if (!members.length) { + throw retryableDesktopRoomCommand('Waiting for a Bot in this Group Chat to reconnect.') + } + + assertLiveLease() + + const message = String(payload.message || '').trim() + + if (!message) { + throw new Error('The Group Chat message is empty.') + } + + const commandId = String(command.command_id || '') + const localAbort = new AbortController() + const onLeaseAbort = () => localAbort.abort('lease-lost') + + signal?.addEventListener('abort', onLeaseAbort, { + once: true + }) + + if (signal?.aborted) { + localAbort.abort('lease-lost') + } + + activeDesktopRoomCommands.set(roomId, { + commandId, + controller: localAbort, + threadId: null + }) + + try { + while (!desktopRoomCommandDisposed) { + if (localAbort.signal.aborted) { + throw retryableDesktopRoomCommand('The command moved to another Desktop.') + } + + const thread = await Promise.resolve( + sendToGroupChat(group, members, message, null, undefined, { + entryId: commandId, + userName: String(payload.actor_display_name || 'Messaging') + }) + ) + + if (!thread) { + throw new Error('The Group Chat could not accept this message.') + } + + const active = activeDesktopRoomCommands.get(roomId) + + if (active?.commandId === commandId) { + active.threadId = thread + } + + if (await waitForDesktopRoomCommandSettlement(group, commandId, localAbort.signal)) { + return { + room_name: group, + thread_id: thread + } + } + } + } catch (error) { + if (localAbort.signal.aborted) { + if (localAbort.signal.reason === 'room-stop') { + return { + room_name: group, + stopped: true + } + } + + await cancelGroupThreadForLeaseLoss(group, members) + throw retryableDesktopRoomCommand('The command moved to another Desktop.') + } + + throw error + } finally { + signal?.removeEventListener('abort', onLeaseAbort) + + if (activeDesktopRoomCommands.get(roomId)?.controller === localAbort) { + activeDesktopRoomCommands.delete(roomId) + } + } + + throw retryableDesktopRoomCommand('Desktop closed before the Group Chat settled.') + } + + if (command.action === 'stop') { + const commandId = String(command.command_id || '') + const payload = command.payload || {} + const targetCommandId = String(payload.target_command_id || '') + const targetThreadId = String(payload.target_thread_id || '') + + if (room.desktopCommandSettled?.[commandId]) { + return { + room_name: group, + stopped: true + } + } + + let active = activeDesktopRoomCommands.get(roomId) + + if (targetCommandId) { + if ( + command.target_command_state === 'failed' && + command.target_result_code === 'superseded_by_stop' && + active?.commandId !== targetCommandId + ) { + return { + room_name: group, + stale: true, + stopped: true + } + } + + for (let attempt = 0; attempt < 40; attempt += 1) { + if ($groupChats.get()[group]?.desktopCommandSettled?.[targetCommandId]) { + return { + room_name: group, + stale: true, + stopped: false + } + } + + active = activeDesktopRoomCommands.get(roomId) + + if (active?.commandId === targetCommandId) { + break + } + + if (active && active.commandId !== targetCommandId) { + return { + room_name: group, + stale: true, + stopped: false + } + } + + if (signal?.aborted) { + throw retryableDesktopRoomCommand('The command moved to another Desktop.') + } + + await new Promise(resolve => setTimeout(resolve, 50)) + } + + if (active?.commandId !== targetCommandId) { + throw retryableDesktopRoomCommand('Waiting for the earlier Group Chat message to start.') + } + } + + const latestThread = [...room.log].reverse().find(item => item?.thread)?.thread || null + const stopThread = targetCommandId ? active?.threadId || targetThreadId || latestThread : targetThreadId + + if (!targetCommandId && targetThreadId && latestThread && targetThreadId !== latestThread) { + return { + room_name: group, + stale: true, + stopped: false + } + } + + if (!stopThread) { + return { + room_name: group, + stale: true, + stopped: false + } + } + + if (targetCommandId) { + active?.controller.abort('room-stop') + } + await stopGroupThread(group, stopThread, members) + updateGroupChat(group, current => ({ + ...current, + desktopCommandSettled: boundedDesktopCommandSettled({ + ...(current.desktopCommandSettled || {}), + ...(targetCommandId && active?.commandId + ? { + [active.commandId]: Date.now() + } + : {}), + [commandId]: Date.now() + }) + })) + + return { + room_name: group, + stopped: true + } + } + + throw new Error('Unsupported Group Chat command.') +} + +async function desktopRoomCommandConnections() { + const byConnection = new Map() + + if (typeof host.profileRoutes === 'function') { + try { + const routes = await host.profileRoutes() + + for (const route of Array.isArray(routes) ? routes : []) { + const profile = String(route?.targetProfile || route?.profile || '') + const connectionId = String(route?.connectionId || '') + + if (profile === 'default' && !byConnection.has(connectionId)) { + byConnection.set(connectionId, route as ProfileRoute) + } + } + } catch { + /* the active route below remains a compatibility fallback */ + } + } + + const active = String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || '') + + if (!byConnection.has(active)) { + byConnection.set(active, { + connectionId: active, + mode: 'remote', + profile: 'default', + targetProfile: 'default' + }) + } + + return [...byConnection.entries()].map(([id, route]) => ({ + id, + route + })) +} + +function syncDesktopRoomCommandRetention(connections: Array<{ id: string; route: ProfileRoute }>) { + if (typeof host.retainProfileSocket !== 'function') { + return + } + + const live = new Set(connections.map(connection => connection.id)) + + for (const [id, release] of [...desktopRoomCommandRetentions]) { + if (!live.has(id)) { + desktopRoomCommandRetentions.delete(id) + + try { + release() + } catch { + /* teardown stays best-effort */ + } + } + } + + if (desktopRoomCommandDisposed) { + return + } + + for (const connection of connections) { + if (!desktopRoomCommandRetentions.has(connection.id)) { + desktopRoomCommandRetentions.set(connection.id, host.retainProfileSocket(connection.route)) + } + } +} + +function releaseDesktopRoomCommandRetention() { + for (const release of desktopRoomCommandRetentions.values()) { + try { + release() + } catch { + /* teardown stays best-effort */ + } + } + + desktopRoomCommandRetentions.clear() +} + +function scheduleDesktopRoomCommandPump(connectionId: null | string = null) { + if (desktopRoomCommandDisposed || typeof setTimeout !== 'function') { + return + } + + const key = connectionId === null ? '*' : String(connectionId) + + desktopRoomCommandPendingConnections.add(key) + desktopRoomStopPendingConnections.add(key) + + if (desktopRoomCommandPushTimer !== null) { + return + } + + desktopRoomCommandPushTimer = setTimeout(() => { + desktopRoomCommandPushTimer = null + + const pending = new Set(desktopRoomCommandPendingConnections) + const stopPending = new Set(desktopRoomStopPendingConnections) + + desktopRoomCommandPendingConnections.clear() + desktopRoomStopPendingConnections.clear() + void runDesktopRoomCommandPump(pending.has('*') ? null : pending) + void runDesktopRoomStopPump(stopPending.has('*') ? null : stopPending) + }, DESKTOP_ROOM_COMMAND_PUSH_DEBOUNCE_MS) +} + +async function runDesktopRoomCommandPump(targetConnectionIds: null | Set = null) { + if (desktopRoomCommandDisposed) { + return + } + + if (desktopRoomCommandRunning) { + desktopRoomCommandRerun = true + + if (targetConnectionIds === null) { + desktopRoomCommandPendingConnections.add('*') + } else { + targetConnectionIds.forEach(id => desktopRoomCommandPendingConnections.add(String(id))) + } + + return + } + + desktopRoomCommandRunning = true + + try { + await ensureDesktopRoomCommandConsumerId() + + const connections = await desktopRoomCommandConnections() + const rooms = desktopCommandEligibleRooms(connections.map(connection => connection.id)) + + if (!Object.keys(rooms).length) { + syncDesktopRoomCommandRetention([]) + + return + } + + syncDesktopRoomCommandRetention(connections) + + const selected = + targetConnectionIds === null + ? connections + : connections.filter(connection => targetConnectionIds.has(connection.id)) + + await runDesktopRoomCommandCycle({ + routes: selected.map(connection => connection.route), + consumerId: desktopRoomCommandConsumerId, + rooms, + request: (route, method, params) => host.requestProfile(route, method, params), + execute: executeDesktopRoomCommand, + actions: ['send'], + shouldContinue: () => !desktopRoomCommandDisposed + }) + } catch { + // A reconnect or older backend leaves durable commands pending. + } finally { + desktopRoomCommandRunning = false + + if (desktopRoomCommandRerun && !desktopRoomCommandDisposed) { + desktopRoomCommandRerun = false + + const pending = [...desktopRoomCommandPendingConnections] + + desktopRoomCommandPendingConnections.clear() + + if (!pending.length || pending.includes('*')) { + scheduleDesktopRoomCommandPump() + } else { + pending.forEach(connectionId => scheduleDesktopRoomCommandPump(connectionId)) + } + } + } +} + +async function runDesktopRoomStopPump(targetConnectionIds: null | Set = null) { + if (desktopRoomCommandDisposed) { + return + } + + if (desktopRoomStopRunning) { + desktopRoomStopRerun = true + + if (targetConnectionIds === null) { + desktopRoomStopPendingConnections.add('*') + } else { + targetConnectionIds.forEach(id => desktopRoomStopPendingConnections.add(String(id))) + } + + return + } + + desktopRoomStopRunning = true + + try { + await ensureDesktopRoomCommandConsumerId() + + const connections = await desktopRoomCommandConnections() + const rooms = desktopCommandEligibleRooms(connections.map(connection => connection.id)) + + const selected = + targetConnectionIds === null + ? connections + : connections.filter(connection => targetConnectionIds.has(connection.id)) + + if (!Object.keys(rooms).length) { + return + } + + await runDesktopRoomCommandCycle({ + routes: selected.map(connection => connection.route), + consumerId: desktopRoomCommandConsumerId, + rooms, + request: (route, method, params) => host.requestProfile(route, method, params), + execute: executeDesktopRoomCommand, + actions: ['stop'], + shouldContinue: () => !desktopRoomCommandDisposed + }) + } catch { + // A reconnect or older backend leaves durable Stops pending. + } finally { + desktopRoomStopRunning = false + + if (desktopRoomStopRerun && !desktopRoomCommandDisposed) { + desktopRoomStopRerun = false + + const pending = [...desktopRoomStopPendingConnections] + + desktopRoomStopPendingConnections.clear() + void runDesktopRoomStopPump(!pending.length || pending.includes('*') ? null : new Set(pending)) + } + } +} + +export async function startDesktopRoomCommandRuntime(storage: PluginContext['storage']) { + desktopRoomStorage = storage + desktopRoomCommandDisposed = false + + await adoptExistingDesktopRooms() + + if (desktopRoomCommandDisposed || desktopRoomStorage !== storage) { + return + } + + if (typeof setInterval !== 'function' || desktopRoomCommandTimer !== null) { + return + } + + void runDesktopRoomCommandPump() + void runDesktopRoomStopPump() + desktopRoomCommandTimer = setInterval(() => { + void runDesktopRoomCommandPump() + void runDesktopRoomStopPump() + }, DESKTOP_ROOM_COMMAND_INTERVAL_MS) + + if (desktopRoomCommandPushUnsub === null && typeof host.onEvent === 'function') { + desktopRoomCommandPushUnsub = host.onEvent('desktop_rooms.commands.pending', event => + scheduleDesktopRoomCommandPump(event?.connectionId ?? null) + ) + } +} + +export function stopDesktopRoomCommandRuntime() { + desktopRoomCommandDisposed = true + for (const active of activeDesktopRoomCommands.values()) { + active.controller.abort('runtime-stopped') + } + activeDesktopRoomCommands.clear() + desktopRoomCommandRerun = false + desktopRoomStopRerun = false + desktopRoomCommandPendingConnections.clear() + desktopRoomStopPendingConnections.clear() + releaseDesktopRoomCommandRetention() + + if (desktopRoomCommandTimer !== null) { + clearInterval(desktopRoomCommandTimer) + desktopRoomCommandTimer = null + } + + if (desktopRoomCommandPushTimer !== null) { + clearTimeout(desktopRoomCommandPushTimer) + desktopRoomCommandPushTimer = null + } + + if (desktopRoomCommandPushUnsub !== null) { + try { + desktopRoomCommandPushUnsub() + } catch { + /* older host disposer */ + } + + desktopRoomCommandPushUnsub = null + } + + desktopRoomStorage = null +} diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx b/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx index ecc13870b5442..44458b37825d0 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx @@ -159,6 +159,7 @@ interface GroupMentionInputProps { 'aria-label'?: string autoFocus?: boolean className?: string + disabled?: boolean members: GroupMember[] onChange: (value: string) => void onPaste?: (event: ClipboardEvent) => void diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-view.test.ts b/apps/desktop/src/plugins/hermes-bots/group-chat-view.test.ts index 4c07972f2426b..3562d064bfcfa 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-view.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-view.test.ts @@ -13,7 +13,14 @@ import type { GroupChat, RosterRow } from './types' // window, and disbanding one — plus the ordering rules that keep the in-pane // fallback from painting a duplicate beside the main tab. -const { host } = vi.hoisted(() => ({ host: {} as Record })) +const { beginHostedRoomMutation, disbandHostedGroupChat, host, markHostedRoomLocallyDeleted, renameHostedGroupChat } = + vi.hoisted(() => ({ + beginHostedRoomMutation: vi.fn(() => 1), + disbandHostedGroupChat: vi.fn(async () => true), + host: {} as Record, + markHostedRoomLocallyDeleted: vi.fn(), + renameHostedGroupChat: vi.fn(async () => true) + })) vi.mock('@hermes/plugin-sdk', async () => { const { pluginSdkMock } = await import('./group-test-utils') @@ -21,6 +28,16 @@ vi.mock('@hermes/plugin-sdk', async () => { return pluginSdkMock(host) }) +vi.mock('./hosted-room-runtime', () => ({ + beginHostedRoomMutation, + disbandHostedGroupChat, + markHostedRoomLocallyDeleted, + readHostedGroupChatAttachment: vi.fn(), + renameHostedGroupChat, + retryHostedGroupChat: vi.fn(), + retryHostedRoomReplay: vi.fn() +})) + interface Room { chat: typeof groupChat data: typeof data @@ -57,9 +74,48 @@ async function loadRoom(): Promise { const durable = (room: Room) => (room.gateway.storage.get('group-chats') || {}) as Record beforeEach(() => { + vi.clearAllMocks() + disbandHostedGroupChat.mockResolvedValue(true) + renameHostedGroupChat.mockResolvedValue(true) runTimersInline() }) +describe('renaming a hosted Group Chat', () => { + it('queues the durable rename before re-keying the local room', async () => { + const room = await loadRoom() + + renameHostedGroupChat.mockResolvedValue(false) + room.chat.$groupChats.set({ + Core: { + continuityMode: 'gateway', + hosted: 'install:studio', + hostedConnectionId: 'host-a', + hostedEpoch: 1, + hostedSeq: 2, + log: [], + members: [ + { + connectionId: 'host-a', + connectionLabel: 'Studio', + name: 'research' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + + await expect(room.view.renameGroupChat('Core', 'Launch', [])).resolves.toBe('Launch') + + expect(renameHostedGroupChat).toHaveBeenCalledWith('Core', 'Launch') + expect(room.chat.$groupChats.get()).not.toHaveProperty('Core') + expect(room.chat.$groupChats.get().Launch).toMatchObject({ + hosted: 'install:studio', + continuityIssue: 'Rename saved. It will sync when Studio is online.' + }) + }) +}) + describe('opening a room', () => { it('follows the main-window tab open and close', async () => { const room = await loadRoom() @@ -138,6 +194,80 @@ describe('opening a room', () => { }) describe('disband', () => { + it('fences an idle hosted deletion before removing local state', async () => { + const room = await loadRoom() + + room.chat.$groupChats.set({ + Hosted: { + continuityMode: 'gateway', + hosted: 'install:home', + hostedConnectionId: 'gateway-a', + hostedEpoch: 1, + log: [], + members: [], + roomId: 'room-hosted', + running: false, + watermarks: {} + } + }) + + await room.view.disbandGroupChat('Hosted', []) + + expect(beginHostedRoomMutation).toHaveBeenCalledWith('room-hosted') + expect(disbandHostedGroupChat).toHaveBeenCalledWith('Hosted') + expect(markHostedRoomLocallyDeleted).toHaveBeenCalledWith('room-hosted') + expect(room.chat.$groupChats.get().Hosted).toBeUndefined() + }) + + it('removes a remotely deleted hosted room locally without requiring the authority again', async () => { + const room = await loadRoom() + + room.chat.$groupChats.set({ + Deleted: { + continuityMode: 'gateway', + hosted: 'install:home', + hostedConnectionId: 'gateway-a', + hostedEpoch: 1, + hostedStatus: { label: 'Deleted', state: 'deleted' }, + log: [], + members: [], + roomId: 'room-deleted', + running: false, + watermarks: {} + } + }) + + await room.view.disbandGroupChat('Deleted', []) + + expect(disbandHostedGroupChat).not.toHaveBeenCalled() + expect(markHostedRoomLocallyDeleted).toHaveBeenCalledWith('room-deleted') + expect(room.chat.$groupChats.get().Deleted).toBeUndefined() + }) + + it('keeps a hosted room when its authority cannot confirm deletion', async () => { + const room = await loadRoom() + + disbandHostedGroupChat.mockRejectedValueOnce(new Error('Reconnect Studio to delete this Group Chat.')) + room.chat.$groupChats.set({ + Hosted: { + continuityMode: 'gateway', + hosted: 'install:home', + hostedConnectionId: 'gateway-a', + hostedEpoch: 1, + log: [], + members: [], + roomId: 'room-hosted', + running: false, + watermarks: {} + } + }) + + await expect(room.view.disbandGroupChat('Hosted', [])).rejects.toThrow('Reconnect Studio') + + expect(markHostedRoomLocallyDeleted).not.toHaveBeenCalled() + expect(room.chat.$groupChats.get().Hosted).toBeTruthy() + }) + it('removes only this membership, room log, workspace and needs-you state', async () => { const room = await loadRoom() room.chat.$groupChats.set({ diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx index e9749dc3c383e..8acae19bfde2a 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx @@ -63,8 +63,11 @@ import { $groupChatWorkspace, $groupClarify, $groupNeedsYou, + groupChatContinuityMode, + groupChatHostedGateway, groupSpeakerLabel, groupThreadOf, + groupThreadReplyCount, scheduleGroupChatServerSync, setGroupChatImage, updateGroupChat @@ -95,6 +98,15 @@ import { import type { GroupComposerDraft, GroupDraftSetter } from './group-panes' import { sendToGroupChat, stopGroupThread } from './group-rounds' import { clearGroupClarify } from './group-turns' +import { reconnectHostedGroupChatPeer } from './hosted-room-reauthorization' +import { + beginHostedRoomMutation, + disbandHostedGroupChat, + markHostedRoomLocallyDeleted, + renameHostedGroupChat, + retryHostedGroupChat, + retryHostedRoomReplay +} from './hosted-room-runtime' import { botsText, useBots } from './i18n' import { displayName, slugify, stripPreviewMarkdown } from './labels' import { botRosterMeta, setBotsWorkspaceOwner } from './routing' @@ -117,6 +129,27 @@ export async function disbandGroupChat(group: string, members: RosterRow[]) { } const prior = all[group] || {} + + if (groupChatHostedGateway(prior)) { + const roomId = String(prior.roomId || '') + const alreadyDeleted = prior.hostedStatus?.state === 'deleted' + + if (!alreadyDeleted) { + beginHostedRoomMutation(roomId) + const acknowledged = await disbandHostedGroupChat(group) + + if (!acknowledged) { + throw new Error( + botsText().group.hostedReconnectToDelete( + prior.members?.find(member => member.connectionLabel)?.connectionLabel || botsText().group.thisHost + ) + ) + } + } + + markHostedRoomLocallyDeleted(roomId) + } + const metaBefore = $botMeta.get() const cleanup = groupDisbandMetadataPlan(group, members, prior, $lastRoster.get(), metaBefore) let metadataPersistence: Promise = Promise.resolve() @@ -192,6 +225,12 @@ export async function disbandGroupChat(group: string, members: RosterRow[]) { sessionOwners: room.sessionOwners || {}, members: Array.isArray(room.members) ? room.members : [], roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + hosted: groupChatHostedGateway(room) || null, + hostedEpoch: Math.max(0, Number(room.hostedEpoch || 0)) || null, + hostedConnectionId: + typeof room.hostedConnectionId === 'string' && room.hostedConnectionId ? room.hostedConnectionId : null, + hostedSeq: Math.max(0, Number(room.hostedSeq || 0)), + continuityMode: groupChatContinuityMode(room), image: room.image || null, syncRevision: Math.max(0, Number(room.syncRevision || 0)) } @@ -238,7 +277,12 @@ export async function disbandGroupChat(group: string, members: RosterRow[]) { * rename, so even a member whose sid is later lost falls back to the same * "Group: " title lookup instead of a fresh "Group: ". * Returns the new name, or null when the target name is taken. */ -async function renameGroupChat(oldName: string, newName: string, members: GroupMember[] | null | undefined) { +export async function renameGroupChat( + oldName: string, + newName: string, + members: GroupMember[] | null | undefined, + { hostedAlreadyRenamed = false }: { hostedAlreadyRenamed?: boolean } = {} +) { const next = String(newName || '') .trim() .slice(0, 64) @@ -269,6 +313,37 @@ async function renameGroupChat(oldName: string, newName: string, members: GroupM return null } + const beforeRename = $groupChats.get()[oldName] + + if (groupChatHostedGateway(beforeRename) && !hostedAlreadyRenamed) { + const connectionName = + beforeRename.members?.find(member => member.connectionLabel)?.connectionLabel || botsText().group.thisHost + + try { + const acknowledged = await renameHostedGroupChat(oldName, next) + + if (!acknowledged) { + updateGroupChat( + oldName, + current => ({ + ...current, + continuityIssue: botsText().group.hostedRenameQueued(connectionName) + }), + { + sync: false + } + ) + } + } catch { + host.notify({ + kind: 'error', + message: botsText().group.hostedRenameFailed(connectionName) + }) + + return null + } + } + // Move the room record wholesale — log, watermarks, sessions, members, // picture, and runtime flags all belong to the same room under its new name. const all: Record = { @@ -367,7 +442,15 @@ function GroupChatSettingsDialog({ group, members, open, onClose, onRenamed }: G const { t } = useI18n() const b = useBots() const rooms: Record = useValue($groupChats) - const current = (rooms[group] || {}).image || null + const room = rooms[group] || {} + const current = room.image || null + const hosted = Boolean(groupChatHostedGateway(room)) + const hostedState = String(room.hostedStatus?.state || '') + + const renameBlocked = + hosted && (room.running === true || ['queued', 'sending', 'stopping', 'working'].includes(hostedState)) + + const continuity = groupChatContinuityMode(room) const [name, setName] = useState(group) const [image, setImage] = useState(current) useEffect(() => { @@ -379,6 +462,10 @@ function GroupChatSettingsDialog({ group, members, open, onClose, onRenamed }: G }, [open, group]) const save = async () => { + if (renameBlocked) { + return + } + const finalName = await renameGroupChat(group, name, members) if (finalName === null) { @@ -410,6 +497,14 @@ function GroupChatSettingsDialog({ group, members, open, onClose, onRenamed }: G {b.group.settingsTitle} {b.group.settingsDesc} +
+
+ {continuity === 'desktop' ? b.group.continuityDesktopTitle : b.group.continuityOnTitle} +
+
+ {continuity === 'desktop' ? b.group.continuityDesktopDesc : b.group.continuityOnDesc} +
+
setName(event.target.value)} value={name} @@ -434,7 +530,7 @@ function GroupChatSettingsDialog({ group, members, open, onClose, onRenamed }: G - @@ -472,6 +568,11 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G log: [], running: false } + const hostedState = String(room.hostedStatus?.state || '') + const hostedDeleted = Boolean(groupChatHostedGateway(room) && hostedState === 'deleted') + const canStop = Boolean( + room.running && hostedState !== 'stopping' && room.hostedStatus?.canStop !== false + ) const composerKey = groupComposerDraftKey(group, room) const composerKeyRef = useRef(composerKey) @@ -519,6 +620,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G })) const [confirmDisband, setConfirmDisband] = useState(false) + const [confirmRetry, setConfirmRetry] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) // Click-to-disambiguate: which log entry is showing its speaker's full // @handle (the roster's name-device form when names collide across @@ -611,6 +713,10 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G // Ctrl/⌘-V a screenshot (or any file) into any composer in this room. const pasteImages = (thread: null | string, event: ClipboardEvent) => { + if (hostedDeleted) { + return + } + const files = [...(event.clipboardData?.files || [])] if (!files.length) { @@ -628,19 +734,30 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G const dropFiles = (event: DragEvent) => { const files = [...(event.dataTransfer?.files || [])] + + if (files.length) { + event.preventDefault() + } + + if (hostedDeleted) { + setDragOver(false) + + return + } + setDragOver(false) if (!files.length) { return } - event.preventDefault() void filesToGroupAttachments(files).then(picked => addImages(replyThread, picked)) } // Collapsible Activity view: collapsed by default — opening it is always an // explicit user action, it never steals focus, and it never auto-scrolls. const [activityOpen, setActivityOpen] = useState(false) + const [reconnecting, setReconnecting] = useState(false) // Subscribe: activity rows re-render as turn events land. useValue($groupActivity) // Pending member questions for THIS room (#90694), oldest first. @@ -723,12 +840,37 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G // Events are epoch-tagged, so a superseded run's history drops out of view. const activityEvents: GroupActivityEntry[] = currentGroupActivity(group) const latestActivity = activityEvents.length ? activityEvents[activityEvents.length - 1] : null + const hostedActivity = groupChatHostedGateway(room) ? room.hostedStatus?.label : null + const retryTaskId = String(room.hostedStatus?.taskId || '') + const reconnectMemberId = String(room.hostedStatus?.reconnectMemberId || '') + + const reconnectRoomMember = async () => { + if (!reconnectMemberId || reconnecting) { + return + } + + setReconnecting(true) + try { + await reconnectHostedGroupChatPeer(group, reconnectMemberId) + } catch { + host.notify({ + kind: 'error', + message: b.group.reconnectFailed + }) + } finally { + setReconnecting(false) + } + } // #94570 shell rewired onto the real primitive (#91868/#94569): the button // must stop the ROUND, not just spray per-member interrupts — without the // epoch bump + holds the loop marched on to the next member. Thread scope: // the run being stopped is the one the latest activity belongs to. const stopRoomRun = async () => { + if (!canStop) { + return + } + await stopGroupThread(group, latestActivity?.thread || null, memberDescriptors()) host.notify({ kind: 'success', @@ -748,11 +890,13 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G > {b.group.activity} - {latestActivity ? ( + {hostedActivity ? ( + {hostedActivity} + ) : latestActivity ? ( {`${groupActivityLabel(latestActivity)} · ${relativeTime(latestActivity.at)}`} ) : null} - {room.running ? ( + {canStop ? ( + ) : null} + {room.hostedStatus?.canReconnect && reconnectMemberId ? ( + + ) : null} + {room.continuityIssue ? ( +
{room.continuityIssue}
+ ) : null} {activityOpen ? (
{activityEvents.length ? ( @@ -779,7 +948,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G {groupActivityLabel(event)} {relativeTime(event.at)} - {event.kind === 'working' ? ( + {canStop && event.kind === 'working' ? (
@@ -1218,7 +1393,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G } }} onDragOver={event => { - if ([...(event.dataTransfer?.types || [])].includes('Files')) { + if (!hostedDeleted && [...(event.dataTransfer?.types || [])].includes('Files')) { event.preventDefault() setDragOver(true) } @@ -1256,9 +1431,11 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
{roomClarifies.length ? b.group.waitingForAnswer - : room.turn - ? b.group.memberThinking(groupSpeakerLabel(room.turn)) - : b.group.roomWorking} + : groupChatHostedGateway(room) && room.hostedStatus?.label + ? room.hostedStatus.label + : room.turn + ? b.group.memberThinking(groupSpeakerLabel(room.turn)) + : b.group.roomWorking}
) : null} {/* Scroll anchor (#89835): rooms opened at scroll position 0, mid- */ @@ -1279,6 +1456,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
pasteImages(null, event)} @@ -1287,7 +1465,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G value={draft} /> {attachButton(null)} -
@@ -1327,6 +1505,16 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G open={confirmDisband} title={b.group.disbandTitle} /> + setConfirmRetry(false)} + onConfirm={async () => { + await retryHostedGroupChat(group, retryTaskId) + }} + open={confirmRetry} + title={b.group.retryTitle} + /> ) } diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts index b88c7056d17fd..2b8b6d9700e5b 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts @@ -273,6 +273,22 @@ describe('duplicate append guard (#93127)', () => { }) describe('threads', () => { + it('repairs cached gateway-second timestamps during hydration', async () => { + const room = await loadRoom() + const seconds = 1_787_969_590.436 + + expect( + room.chat.assignLegacyThreads([ + { + at: seconds, + from: { kind: 'user', name: 'You' }, + text: 'Hello', + thread: 'thread-1' + } + ])[0].at + ).toBe(seconds * 1000) + }) + it('hydration assigns legacy thread ids — a lull splits, follow-ups stay together', async () => { const { chat } = await loadRoom() const minute = 60000 @@ -295,6 +311,25 @@ describe('threads', () => { expect(log[0].thread).not.toBe(log[4].thread) expect(log[4].thread).toBe(log[5].thread) }) + + it('counts only visible replies after the thread head', async () => { + const { chat } = await loadRoom() + + const log = [ + { at: 1, from: { kind: 'user', name: 'You' }, text: 'Start', thread: 'thread-1' }, + { at: 2, from: { kind: 'member', name: 'research' }, text: '', thread: 'thread-1' }, + { at: 3, from: { kind: 'member', name: 'research' }, text: 'Visible', thread: 'thread-1' }, + { + at: 4, + from: { kind: 'member', name: 'builder' }, + images: [{ data: 'data:image/png;base64,x', kind: 'image', name: 'result.png' }], + text: '', + thread: 'thread-1' + } + ] as GroupMessage[] + + expect(chat.groupThreadReplyCount(log, 'thread-1')).toBe(2) + }) }) describe('durable projection', () => { @@ -445,6 +480,26 @@ describe('gateway mirror', () => { expect(Object.keys(snapshot.rooms)).toEqual([]) }) + it('publishes a newly created silent room so messaging clients can discover it', async () => { + const { chat } = await loadRoom() + + const snapshot = chat.groupChatSyncSnapshot({ + Planning: { + desktopAuthorityHash: 'a'.repeat(64), + log: [], + members: [{ name: 'research' }, { name: 'builder' }], + roomId: 'room-planning', + watermarks: {} + } + }) + + expect(snapshot.rooms['id:room-planning']).toMatchObject({ + desktopAuthorityHash: 'a'.repeat(64), + name: 'Planning', + roomId: 'room-planning' + }) + }) + it('an empty hydrate cannot erase a shared room mirror', async () => { const room = await loadRoom() const before = room.gateway.rpc.length @@ -468,6 +523,23 @@ describe('gateway mirror', () => { }) describe('snapshot merge', () => { + it('collapses identity-free projection echoes when authoritative replay arrives', async () => { + const { chat } = await loadRoom() + + const fallback = { + at: 100, + from: { kind: 'member' as const, name: 'research' }, + text: 'Complete', + thread: 'thread-1' + } + + const echoes = Array.from({ length: 96 }, () => ({ ...fallback })) + + const merged = chat.mergeGroupChatSyncEntries(echoes, [{ ...fallback, eventId: 'event-9', id: 'event-9', seq: 9 }]) + + expect(merged).toEqual([{ ...fallback, eventId: 'event-9', id: 'event-9', seq: 9 }]) + }) + it('pull-before-push preserves disjoint rooms, messages and members', async () => { const { chat } = await loadRoom() diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.ts index 9949c57824b15..5d8cd8aeecdf8 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.ts @@ -56,6 +56,10 @@ let groupChatSyncTimer: ReturnType | null = null /** One room inside the bounded ui_meta projection: a compacted log plus the * identity fields, without any of `GroupChat`'s runtime/orchestration state. */ interface GroupChatSyncRoom { + continuityMode?: 'desktop' | 'distributed' | 'gateway' + desktopAuthorityHash?: null | string + hosted?: null | string + hostedEpoch?: null | number image?: null | string log: GroupMessage[] members?: GroupMember[] @@ -124,6 +128,95 @@ export function groupChatRoomKey(name: string, room: GroupChat) { return typeof room?.roomId === 'string' && room.roomId ? `id:${room.roomId}` : `name:${String(name)}` } +/** Stable authority id for a gateway-hosted room. Presence is an execution + * fence: a Desktop that cannot reach that gateway must not start a second + * local round driver for the same room. */ +export function groupChatHostedGateway(room: null | Partial> | undefined) { + return typeof room?.hosted === 'string' ? room.hosted.trim().slice(0, 128) : '' +} + +/** Monotonic authority epoch. Legacy hosted records predate the explicit + * field and safely mean epoch 1. */ +export function groupChatHostedEpoch(room: null | Partial> | undefined) { + const epoch = Number(room?.hostedEpoch || 0) + + if (Number.isSafeInteger(epoch) && epoch >= 1) { + return epoch + } + + return groupChatHostedGateway(room) ? 1 : 0 +} + +export function groupChatContinuityMode( + room: null | Partial> | undefined +) { + if (!groupChatHostedGateway(room)) { + return 'desktop' as const + } + + return room?.continuityMode === 'distributed' ? ('distributed' as const) : ('gateway' as const) +} + +/** Apply authority only from `groups.state`, never from the client-writable + * ui_meta display projection. A conflicting owner cannot replace an existing + * fence without a server-issued authority transfer receipt. */ +export function applyHostedRoomAuthority(room: GroupChat, serverRoom: Record): GroupChat { + const authorityGateway = groupChatHostedGateway({ + hosted: typeof serverRoom.authority_gateway_id === 'string' ? serverRoom.authority_gateway_id : null + }) + + const authorityEpoch = Number(serverRoom.authority_epoch || 0) + const roomId = typeof room?.roomId === 'string' ? room.roomId : '' + const serverRoomId = typeof serverRoom.room_id === 'string' ? serverRoom.room_id : '' + + if ( + !authorityGateway || + !Number.isSafeInteger(authorityEpoch) || + authorityEpoch < 1 || + (roomId && serverRoomId && roomId !== serverRoomId) + ) { + return room + } + + const currentGateway = groupChatHostedGateway(room) + const currentEpoch = groupChatHostedEpoch(room) + + const claim = + serverRoom.authority_claim && typeof serverRoom.authority_claim === 'object' + ? (serverRoom.authority_claim as Record) + : null + + const actor = claim?.actor && typeof claim.actor === 'object' ? (claim.actor as Record) : null + + const payload = + claim?.payload && typeof claim.payload === 'object' ? (claim.payload as Record) : null + + const transferProven = Boolean( + claim?.kind === 'authority.claimed' && + actor?.kind === 'system' && + actor?.id === 'authority-control' && + Number(claim?.authority_epoch || 0) === authorityEpoch && + payload?.previous_gateway_id === currentGateway && + payload?.authority_gateway_id === authorityGateway && + Number(payload?.authority_epoch || 0) === authorityEpoch + ) + + if ( + currentEpoch > authorityEpoch || + (currentGateway && currentGateway !== authorityGateway && !transferProven) || + (currentEpoch === authorityEpoch && currentGateway && currentGateway !== authorityGateway) + ) { + return room + } + + return { + ...room, + hosted: authorityGateway, + hostedEpoch: authorityEpoch, + continuityMode: room.continuityMode === 'distributed' ? 'distributed' : 'gateway' + } +} + /** Lift any historical projection shape (v1 wall-clock, v2 name-keyed) to * the v3 room-key shape so one merge path serves mixed-version fleets. */ function normalizeGroupChatSyncSnapshot(snapshot: GroupChatSyncSnapshot | null | undefined): GroupChatSyncSnapshot { @@ -186,7 +279,14 @@ export function groupChatSyncSnapshot( const ranked = Object.entries(all || {}) // Empty runtime tombstones are used to stop an in-flight room after // disband. They are not real rooms and must never reappear on mobile. - .filter(([, room]) => room && Array.isArray(room.log) && room.log.length > 0) + .filter( + ([, room]) => + room && + !room.tombstone && + Array.isArray(room.log) && + (room.log.length > 0 || + (typeof room.roomId === 'string' && room.roomId && Array.isArray(room.members) && room.members.length >= 2)) + ) .sort(([, left], [, right]) => { const leftAt = Number(left.log[left.log.length - 1]?.at || 0) const rightAt = Number(right.log[right.log.length - 1]?.at || 0) @@ -235,7 +335,7 @@ export function groupChatSyncSnapshot( ? { thread: String(entry.thread).slice(0, 128) } - : {}) + : {}), })) const compact: GroupChatSyncRoom = { @@ -245,6 +345,17 @@ export function groupChatSyncSnapshot( roomId: String(room.roomId).slice(0, 128) } : {}), + // Presence is the mixed-version contract. Older clients omit these + // fields; hosted-aware clients must preserve an existing non-empty + // authority fence instead of interpreting omission as a local takeover. + hosted: groupChatHostedGateway(room) || null, + hostedEpoch: groupChatHostedEpoch(room) || null, + ...(typeof room.desktopAuthorityHash === 'string' && /^[a-f0-9]{64}$/.test(room.desktopAuthorityHash) + ? { + desktopAuthorityHash: room.desktopAuthorityHash + } + : {}), + continuityMode: groupChatContinuityMode(room), log, revision: Math.max(0, Number(room?.syncRevision ?? room?.revision ?? 0)), members: (Array.isArray(room.members) ? room.members : []).slice(0, GROUP_CHAT_MAX_MEMBERS).map(member => ({ @@ -302,10 +413,24 @@ export function groupChatSyncSnapshot( } function groupChatSyncEntryKey(entry: GroupMessage) { + const seq = groupChatSyncSequence(entry) + + if (seq !== null) { + return `seq:${seq}` + } + + if (entry?.eventId) { + return `event:${String(entry.eventId)}` + } + if (entry?.id) { return `id:${String(entry.id)}` } + return `fallback:${groupChatSyncFallbackKey(entry)}` +} + +function groupChatSyncFallbackKey(entry: GroupMessage) { return JSON.stringify([ Number(entry?.at || 0), String(entry?.from?.kind || ''), @@ -323,6 +448,112 @@ function groupChatSyncEntryKey(entry: GroupMessage) { ]) } +export function groupChatSyncSequence(entry: GroupMessage | null | undefined) { + const seq = Number(entry?.seq) + + return Number.isSafeInteger(seq) && seq > 0 ? seq : null +} + +function compareGroupChatSyncEntries(left: GroupMessage, right: GroupMessage) { + const leftSeq = groupChatSyncSequence(left) + const rightSeq = groupChatSyncSequence(right) + + if (leftSeq !== null && rightSeq !== null) { + return leftSeq - rightSeq || groupChatSyncEntryKey(left).localeCompare(groupChatSyncEntryKey(right)) + } + + const byTime = Number(left?.at || 0) - Number(right?.at || 0) + + return byTime || groupChatSyncEntryKey(left).localeCompare(groupChatSyncEntryKey(right)) +} + +/** Union a hosted replay with local/compact mirrors without briefly showing + * both the optimistic event id and its authoritative sequence twin. */ +export function mergeGroupChatSyncEntries(...logs: GroupMessage[][]) { + const entries: GroupMessage[] = [] + const byId = new Map() + const bySeq = new Map() + const byFallback = new Map() + + const remember = (entry: GroupMessage, index: number) => { + if (entry?.eventId) { + byId.set(`event:${String(entry.eventId)}`, index) + byId.set(`id:${String(entry.eventId)}`, index) + } + + if (entry?.id) { + byId.set(`id:${String(entry.id)}`, index) + byId.set(`event:${String(entry.id)}`, index) + } + + const seq = groupChatSyncSequence(entry) + + if (seq !== null) { + bySeq.set(seq, index) + } + + byFallback.set(groupChatSyncFallbackKey(entry), index) + } + + for (const entry of logs.flat()) { + const eventId = entry?.eventId ? `event:${String(entry.eventId)}` : '' + const id = entry?.id ? `id:${String(entry.id)}` : '' + const seq = groupChatSyncSequence(entry) + let index = eventId ? byId.get(eventId) : id ? byId.get(id) : undefined + + if (index === undefined && seq !== null) { + index = bySeq.get(seq) + } + + if (index === undefined) { + index = byFallback.get(groupChatSyncFallbackKey(entry)) + } + + if (index === undefined) { + index = entries.length + entries.push(entry) + remember(entry, index) + + continue + } + + const prior = entries[index] + const authoritativeSeq = groupChatSyncSequence(prior) ?? seq + + const merged: GroupMessage = { + ...prior, + ...entry, + ...(prior?.images && !entry?.images + ? { + images: prior.images + } + : {}), + ...(prior?.id && !entry?.id + ? { + id: prior.id + } + : {}), + ...(prior?.eventId && !entry?.eventId + ? { + eventId: prior.eventId + } + : {}), + ...(authoritativeSeq !== null + ? { + seq: authoritativeSeq + } + : {}) + } + + entries[index] = merged + remember(prior, index) + remember(entry, index) + remember(merged, index) + } + + return entries.sort(compareGroupChatSyncEntries) +} + /** Members dedupe on durable identity — the same (connectionId, name) pair * botRosterKey seats them by everywhere else. `connectionLabel` and `handle` * are display strings each machine re-derives (a connection rename, an older @@ -421,26 +652,33 @@ export function mergeGroupChatSyncSnapshots( ? Math.max(0, Number(writeRevision || 0)) : Math.max(0, Number(localRoom?.revision || 0)) - const entries = new Map() - - for (const entry of [...(remoteRoom?.log || []), ...(localRoom?.log || [])]) { - entries.set(groupChatSyncEntryKey(entry), entry) - } + const entries = mergeGroupChatSyncEntries(remoteRoom?.log || [], localRoom?.log || []) // Identity fields (display name, membership, picture) follow the higher // revision; a tie unions members and prefers the local writer's fields. let identity: GroupChatSyncRoom | undefined let members: GroupMember[] let image: null | string | undefined + let hosted: null | string | undefined + let hostedEpoch = 0 + let hostedPresent = false + const remoteHostedPresent = Object.prototype.hasOwnProperty.call(remoteRoom || {}, 'hosted') + const localHostedPresent = Object.prototype.hasOwnProperty.call(localRoom || {}, 'hosted') if (localRevision > remoteRevision) { identity = localRoom members = [...(localRoom?.members || [])] image = localRoom?.image + hostedPresent = localHostedPresent || remoteHostedPresent + hosted = localHostedPresent ? groupChatHostedGateway(localRoom) : groupChatHostedGateway(remoteRoom) + hostedEpoch = localHostedPresent ? groupChatHostedEpoch(localRoom) : groupChatHostedEpoch(remoteRoom) } else if (remoteRevision > localRevision) { identity = remoteRoom members = [...(remoteRoom?.members || [])] image = remoteRoom?.image + hostedPresent = remoteHostedPresent || localHostedPresent + hosted = remoteHostedPresent ? groupChatHostedGateway(remoteRoom) : groupChatHostedGateway(localRoom) + hostedEpoch = remoteHostedPresent ? groupChatHostedEpoch(remoteRoom) : groupChatHostedEpoch(localRoom) } else { identity = localRoom || remoteRoom const byId = new Map() @@ -451,6 +689,24 @@ export function mergeGroupChatSyncSnapshots( members = [...byId.values()] image = Object.prototype.hasOwnProperty.call(localRoom || {}, 'image') ? localRoom.image : remoteRoom?.image + hostedPresent = localHostedPresent || remoteHostedPresent + hosted = localHostedPresent ? groupChatHostedGateway(localRoom) : groupChatHostedGateway(remoteRoom) + hostedEpoch = localHostedPresent ? groupChatHostedEpoch(localRoom) : groupChatHostedEpoch(remoteRoom) + } + + // ui_meta is a display cache, not an authority receipt. Preserve any + // existing non-empty fence even if a newer legacy writer omitted it. + const remoteHosted = groupChatHostedGateway(remoteRoom) + const localHosted = groupChatHostedGateway(localRoom) + + if (localHosted) { + hostedPresent = true + hosted = localHosted + hostedEpoch = groupChatHostedEpoch(localRoom) + } else if (remoteHosted) { + hostedPresent = true + hosted = remoteHosted + hostedEpoch = groupChatHostedEpoch(remoteRoom) } rooms[key] = { @@ -464,13 +720,20 @@ export function mergeGroupChatSyncSnapshots( roomId: identity?.roomId || key.slice(3) } : {}), - log: [...entries.values()].sort((left, right) => { - const byTime = Number(left?.at || 0) - Number(right?.at || 0) - - return byTime || groupChatSyncEntryKey(left).localeCompare(groupChatSyncEntryKey(right)) - }), + log: entries, members, revision: Math.max(remoteRevision, localRevision), + ...(hostedPresent + ? { + hosted: hosted || null, + hostedEpoch: hostedEpoch || null, + continuityMode: hosted + ? identity?.continuityMode === 'distributed' + ? ('distributed' as const) + : ('gateway' as const) + : ('desktop' as const) + } + : {}), ...(typeof image === 'string' && image ? { image @@ -609,26 +872,10 @@ export function mergeRemoteGroupChatSnapshotIntoRooms( const remoteRevision = Math.max(0, Number(projected.revision || 0)) const localRevision = Math.max(0, Number(existing.syncRevision || 0)) - const entries = new Map( - (Array.isArray(existing.log) ? existing.log : []).map(entry => [groupChatSyncEntryKey(entry), entry]) - ) - const members = new Map( (Array.isArray(existing.members) ? existing.members : []).map(member => [groupChatSyncMemberKey(member), member]) ) - for (const entry of projected.log) { - const entryKey = groupChatSyncEntryKey(entry) - - // The projection is COMPACT (truncated text, no images). When the same - // entry exists locally, the local rich copy is authoritative — merging - // the compact twin over it would strip attachments and retrigger - // watermark deltas for members that already saw it (phantom rounds). - if (!entries.has(entryKey)) { - entries.set(entryKey, entry) - } - } - const isPreserved = preserved.has(displayName) || (localName && preserved.has(localName)) if (!isPreserved) { @@ -644,15 +891,15 @@ export function mergeRemoteGroupChatSnapshotIntoRooms( } } - const log = assignLegacyThreads( - [...entries.values()].sort((left, right) => { - const byTime = Number(left?.at || 0) - Number(right?.at || 0) - - return byTime || groupChatSyncEntryKey(left).localeCompare(groupChatSyncEntryKey(right)) - }) - ) + // Projection copies are compact and therefore go first: the local rich + // twin overlays them while retaining the authoritative hosted sequence. + const log = assignLegacyThreads(mergeGroupChatSyncEntries(projected.log, existing.log || [])) const bounded = trimGroupChatLog(log, existing.watermarks || {}) + const projectedHosted = groupChatHostedGateway(projected) + const existingHosted = groupChatHostedGateway(existing) + const cachedHosted = existingHosted || projectedHosted + const cachedHostedEpoch = existingHosted ? groupChatHostedEpoch(existing) : groupChatHostedEpoch(projected) // A remote rename with a higher revision moves the local record to the // new display name; local views keyed by the old name follow on the @@ -680,6 +927,20 @@ export function mergeRemoteGroupChatSnapshotIntoRooms( : remoteRevision >= localRevision && Object.prototype.hasOwnProperty.call(projected, 'image') ? projected.image || null : existing.image || null, + desktopAuthorityHash: + (typeof existing.desktopAuthorityHash === 'string' && /^[a-f0-9]{64}$/.test(existing.desktopAuthorityHash) + ? existing.desktopAuthorityHash + : null) || + (typeof projected.desktopAuthorityHash === 'string' && /^[a-f0-9]{64}$/.test(projected.desktopAuthorityHash) + ? projected.desktopAuthorityHash + : null), + hosted: cachedHosted || null, + hostedEpoch: cachedHostedEpoch || null, + continuityMode: cachedHosted + ? existing.continuityMode === 'distributed' || projected.continuityMode === 'distributed' + ? 'distributed' + : 'gateway' + : 'desktop', syncRevision: isPreserved ? localRevision : Math.max(remoteRevision, localRevision), epoch: Number(existing.epoch || 0), running: Boolean(existing.running) @@ -720,6 +981,16 @@ export function mergeRemoteGroupChatSnapshotIntoRooms( return rooms } +export function boundedDesktopCommandSettled(value: unknown, limit = 128) { + return Object.fromEntries( + Object.entries(value && typeof value === 'object' ? value : {}) + .map(([id, at]) => [String(id), Math.max(0, Number(at || 0))] as const) + .filter(([id]) => id) + .sort(([, left], [, right]) => right - left) + .slice(0, limit) + ) +} + export function durableGroupChatRooms(all: Record = $groupChats.get()) { const durable: Record = {} @@ -748,6 +1019,17 @@ export function durableGroupChatRooms(all: Record = $groupCha // name-keyed identity — same field updateGroupChat's inline map // already carries. roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + desktopCoordinatorId: + typeof room.desktopCoordinatorId === 'string' && room.desktopCoordinatorId ? room.desktopCoordinatorId : null, + desktopAuthorityToken: + typeof room.desktopAuthorityToken === 'string' && room.desktopAuthorityToken ? room.desktopAuthorityToken : null, + desktopCommandSettled: boundedDesktopCommandSettled(room.desktopCommandSettled), + hosted: groupChatHostedGateway(room) || null, + hostedEpoch: groupChatHostedEpoch(room) || null, + hostedConnectionId: + typeof room.hostedConnectionId === 'string' && room.hostedConnectionId ? room.hostedConnectionId : null, + hostedSeq: Math.max(0, Number(room.hostedSeq || 0)), + continuityMode: groupChatContinuityMode(room), image: room.image || null, syncRevision: Math.max(0, Number(room.syncRevision || 0)) } @@ -1346,6 +1628,17 @@ export function updateGroupChat( members: Array.isArray(room.members) ? room.members : [], // Immutable room identity: the member-session title for new rooms. roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + desktopCoordinatorId: + typeof room.desktopCoordinatorId === 'string' && room.desktopCoordinatorId ? room.desktopCoordinatorId : null, + desktopAuthorityToken: + typeof room.desktopAuthorityToken === 'string' && room.desktopAuthorityToken ? room.desktopAuthorityToken : null, + desktopCommandSettled: boundedDesktopCommandSettled(room.desktopCommandSettled), + hosted: groupChatHostedGateway(room) || null, + hostedEpoch: groupChatHostedEpoch(room) || null, + hostedConnectionId: + typeof room.hostedConnectionId === 'string' && room.hostedConnectionId ? room.hostedConnectionId : null, + hostedSeq: Math.max(0, Number(room.hostedSeq || 0)), + continuityMode: groupChatContinuityMode(room), // Room picture (small data URL, same normalization as bot avatars). image: room.image || null, syncRevision: Math.max(0, Number(room.syncRevision || 0)) @@ -1421,14 +1714,20 @@ export function appendGroupChatEntry( from: GroupMessageAuthor, text: string, thread?: null | string, - images?: Attachment[] + images?: Attachment[], + { entryId = '', external = false }: { entryId?: string; external?: boolean } = {} ): GroupMessage { const entry: GroupMessage = { - id: groupChatEntryId(), + id: entryId || groupChatEntryId(), at: Date.now(), from, text: normalizeGroupChatText(text), - thread: thread || 'legacy' + thread: thread || 'legacy', + ...(external + ? { + external: true + } + : {}) } if (Array.isArray(images) && images.length) { @@ -1556,6 +1855,17 @@ export function groupThreadOf(entry: GroupMessage): string { return entry?.thread || 'legacy' } +/** Count only transcript rows a person can actually see, excluding the + * thread head itself. Status-only replay events must not inflate replies. */ +export function groupThreadReplyCount(log: GroupMessage[], thread: string): number { + const visible = (log || []).filter( + entry => + groupThreadOf(entry) === thread && (Boolean(String(entry?.text || '').trim()) || Boolean(entry?.images?.length)) + ) + + return Math.max(0, visible.length - 1) +} + export function mintGroupThreadId(): string { return `t${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` } @@ -1569,14 +1879,25 @@ export function assignLegacyThreads(log: GroupMessage[]): GroupMessage[] { let current: null | string = null let n = 0 - return (log || []).map((entry, i) => { + const normalized = (log || []).map(entry => { + const at = Number(entry?.at || 0) + + return at >= 1_000_000_000 && at < 1_000_000_000_000 + ? { + ...entry, + at: at * 1000 + } + : entry + }) + + return normalized.map((entry, i) => { if (entry?.thread) { current = null return entry } - const prev = log[i - 1] + const prev = normalized[i - 1] const lull = !prev || (entry.at || 0) - (prev.at || 0) > GROUP_THREAD_GAP_MS if (!current || (entry.from?.kind === 'user' && lull)) { diff --git a/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx b/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx new file mode 100644 index 0000000000000..9f20e4b0fc977 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx @@ -0,0 +1,425 @@ +import type * as HermesSdk from '@hermes/plugin-sdk' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as DataModule from './data' +import type { HostedRoomProbe } from './hosted-room-runtime' +import { translateBots } from './i18n-test-helper' +import type { RosterRow } from './types' + +const mocks = vi.hoisted(() => ({ + createAutonomousHostedGroupChat: vi.fn(), + markHostedRoomLocallyDeleted: vi.fn(), + notify: vi.fn(), + prepareDesktopRoomAuthority: vi.fn(async () => ({ + desktopAuthorityHash: 'a'.repeat(64), + desktopAuthorityToken: 'authority:test', + desktopCoordinatorId: 'desktop:test' + })), + probeHostedRoomMembers: vi.fn(), + saveBotMeta: vi.fn(async (_owner: unknown, _patch: unknown) => undefined) +})) + +vi.mock('@hermes/plugin-sdk', async importOriginal => { + const original = await importOriginal() + + return { + ...original, + host: { + ...original.host, + notify: mocks.notify + }, + usePluginI18n: () => translateBots + } +}) + +vi.mock('./data', async importOriginal => { + const original = await importOriginal() + + return { + ...original, + saveBotMeta: mocks.saveBotMeta + } +}) + +vi.mock('./hosted-room-runtime', () => ({ + createAutonomousHostedGroupChat: mocks.createAutonomousHostedGroupChat, + describeHostedRoomCreationError: () => null, + markHostedRoomLocallyDeleted: mocks.markHostedRoomLocallyDeleted, + probeHostedRoomMembers: mocks.probeHostedRoomMembers +})) + +vi.mock('./desktop-room-command-runtime', () => ({ + prepareDesktopRoomAuthority: mocks.prepareDesktopRoomAuthority +})) + +const roster: RosterRow[] = [ + { + connectionId: 'host-a', + connectionLabel: 'Studio', + name: 'research', + remoteSource: true, + sourceScoped: true, + targetProfile: 'research' + }, + { + connectionId: 'host-a', + connectionLabel: 'Studio', + name: 'builder', + remoteSource: true, + sourceScoped: true, + targetProfile: 'builder' + } +] + +const eligibleProbe: HostedRoomProbe = { + eligible: true, + capability: { + authorityId: 'install:studio', + connectionId: 'host-a', + exactPeerGrantRevoke: false, + kind: 'driver-capable', + limits: { + attachments: false, + automaticFailover: false, + crossGatewayMembers: true + }, + persistentProcess: true, + routeGrantFingerprint: false, + reciprocalControl: false, + reason: null, + roomLink: null + }, + capabilities: {}, + route: { + connectionId: 'host-a', + homeConnectionId: 'host-a', + kind: 'single-gateway', + limits: { + attachments: false, + automaticFailover: false, + crossGatewayMembers: true + }, + memberConnectionIds: ['host-a', 'host-a'], + reason: null, + remoteConnectionIds: [] + }, + routes: { + 'host-a': { + connectionId: 'host-a', + mode: 'remote', + profile: 'default', + targetProfile: 'default' + } + } +} + +beforeAll(() => { + Element.prototype.scrollIntoView = () => undefined + Element.prototype.hasPointerCapture = () => false + Element.prototype.releasePointerCapture = () => undefined + Element.prototype.setPointerCapture = () => undefined +}) + +beforeEach(async () => { + vi.clearAllMocks() + mocks.probeHostedRoomMembers.mockResolvedValue(eligibleProbe) + mocks.createAutonomousHostedGroupChat.mockResolvedValue({ + authorityId: 'install:studio', + authorityEpoch: 1, + connectionId: 'host-a', + continuityMode: 'gateway' + }) + + const { $groupChats } = await import('./group-chat') + + $groupChats.set({}) +}) + +afterEach(() => { + cleanup() +}) + +async function renderSelectedGroup(rows: RosterRow[] = roster) { + const { CreateGroupChatDialog } = await import('./create-dialog') + + const Harness = () => { + const [open, setOpen] = useState(true) + + return setOpen(false)} open={open} roster={rows} /> + } + + render() + + const checkboxes = screen.getAllByRole('checkbox') + + fireEvent.click(checkboxes[0]) + fireEvent.click(checkboxes[1]) + + await waitFor(() => expect(mocks.probeHostedRoomMembers).toHaveBeenCalledTimes(1)) + + return screen.getByRole('button', { + name: 'Create Group (2)' + }) as HTMLButtonElement +} + +describe('automatic Group Chat continuity', () => { + it('shows the required empty copy when no bots exist', async () => { + const { CreateGroupChatDialog } = await import('./create-dialog') + + await act(async () => { + render( undefined} open roster={[]} />) + }) + + expect(screen.getByText('No bots yet. Create a bot first.')).toBeTruthy() + }) + + it('has no creation switch and selects hosted continuity automatically when eligible', async () => { + const create = await renderSelectedGroup() + + expect(screen.getByText('New group chat')).toBeTruthy() + expect(screen.getByText('Choose 2–6 Bots.')).toBeTruthy() + expect(screen.queryByRole('switch')).toBeNull() + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + await waitFor(() => expect(mocks.createAutonomousHostedGroupChat).toHaveBeenCalledTimes(1)) + + const { $groupChats } = await import('./group-chat') + + await waitFor(() => expect(Object.values($groupChats.get())).toHaveLength(1)) + const created = Object.values($groupChats.get())[0] + + expect(created).toMatchObject({ + continuityMode: 'gateway', + hosted: 'install:studio', + hostedConnectionId: 'host-a', + hostedEpoch: 1 + }) + expect(mocks.notify).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('pause when Desktop closes') + }) + ) + }) + + it('keeps each selected Bot on its captured gateway when the roster refreshes during creation', async () => { + const rows: RosterRow[] = [ + { + connectionId: 'host-a', + connectionLabel: 'Studio', + name: 'research', + remoteSource: true, + sourceScoped: true, + targetProfile: 'research' + }, + { + connectionId: 'host-b', + connectionLabel: 'VPS', + name: 'builder', + remoteSource: true, + sourceScoped: true, + targetProfile: 'builder' + } + ] + + mocks.probeHostedRoomMembers.mockResolvedValue({ + ...eligibleProbe, + capabilities: { + 'host-a': eligibleProbe.capability, + 'host-b': { + ...eligibleProbe.capability, + authorityId: 'install:vps', + connectionId: 'host-b' + } + }, + route: { + ...eligibleProbe.route, + kind: 'multi-gateway', + memberConnectionIds: ['host-a', 'host-b'], + remoteConnectionIds: ['host-b'] + }, + routes: { + ...eligibleProbe.routes, + 'host-b': { + connectionId: 'host-b', + mode: 'remote', + profile: 'default', + targetProfile: 'default' + } + } + }) + mocks.createAutonomousHostedGroupChat.mockImplementation(async () => { + rows[1].connectionId = 'host-a' + rows[1].connectionLabel = 'Studio' + + return { + authorityId: 'install:studio', + authorityEpoch: 1, + connectionId: 'host-a', + continuityMode: 'distributed' + } + }) + + const create = await renderSelectedGroup(rows) + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + const { $groupChats } = await import('./group-chat') + const created = Object.values($groupChats.get())[0] + + expect(created.continuityMode).toBe('distributed') + expect(created.members).toEqual([ + expect.objectContaining({ connectionId: 'host-a', name: 'research' }), + expect.objectContaining({ connectionId: 'host-b', name: 'builder' }) + ]) + expect(mocks.createAutonomousHostedGroupChat.mock.calls[0][0].members).toEqual([ + expect.objectContaining({ + member: expect.objectContaining({ connectionId: 'host-a', name: 'research' }) + }), + expect.objectContaining({ + member: expect.objectContaining({ connectionId: 'host-b', name: 'builder' }) + }) + ]) + expect(mocks.saveBotMeta.mock.calls.map(([owner]) => (owner as { connectionId?: string }).connectionId)).toEqual([ + 'host-a', + 'host-b' + ]) + }) + + it('keeps Create disabled until the probe settles', async () => { + let settleProbe: (probe: HostedRoomProbe) => void = () => undefined + + mocks.probeHostedRoomMembers.mockImplementation( + () => + new Promise(resolve => { + settleProbe = resolve + }) + ) + + const create = await renderSelectedGroup() + + expect(create.disabled).toBe(true) + settleProbe(eligibleProbe) + await waitFor(() => expect(create.disabled).toBe(false)) + }) + + it('creates a classic Desktop Group Chat when the probe fails', async () => { + mocks.probeHostedRoomMembers.mockRejectedValue(new Error('offline')) + const create = await renderSelectedGroup() + + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + const { $groupChats } = await import('./group-chat') + + await waitFor(() => expect(Object.values($groupChats.get())).toHaveLength(1)) + const created = Object.values($groupChats.get())[0] + + expect(created.continuityMode).toBe('desktop') + expect(created.hosted ?? null).toBeNull() + expect(mocks.createAutonomousHostedGroupChat).not.toHaveBeenCalled() + }) + + it('keeps unscoped local metadata owners local in classic creation', async () => { + mocks.probeHostedRoomMembers.mockRejectedValue(new Error('unsupported')) + const create = await renderSelectedGroup([{ name: 'research' }, { name: 'builder' }]) + + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + const { $groupChats } = await import('./group-chat') + + await waitFor(() => expect(Object.values($groupChats.get())).toHaveLength(1)) + const created = Object.values($groupChats.get())[0] + + expect(created.continuityMode).toBe('desktop') + expect( + mocks.saveBotMeta.mock.calls.map(([owner]) => ({ + name: (owner as RosterRow).name, + remoteSource: Boolean((owner as RosterRow).remoteSource), + sourceScoped: Boolean((owner as RosterRow).sourceScoped) + })) + ).toEqual([ + { name: 'research', remoteSource: false, sourceScoped: false }, + { name: 'builder', remoteSource: false, sourceScoped: false } + ]) + }) + + it('falls back to Desktop and shows one concise notice when hosted creation fails', async () => { + mocks.createAutonomousHostedGroupChat.mockImplementation(async ({ name, roomId }) => { + const { updateGroupChat } = await import('./group-chat') + + updateGroupChat( + name, + room => ({ + ...room, + continuityMode: 'gateway', + hosted: 'install:studio', + hostedConnectionId: 'host-a', + hostedEpoch: 1, + roomId + }), + { sync: false } + ) + + throw new Error('device refused') + }) + const create = await renderSelectedGroup() + + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + const { $groupChats } = await import('./group-chat') + + await waitFor(() => expect(Object.values($groupChats.get())).toHaveLength(1)) + const created = Object.values($groupChats.get())[0] + const attemptedRoomId = mocks.createAutonomousHostedGroupChat.mock.calls[0][0].roomId + + expect(created.continuityMode).toBe('desktop') + expect(created.hosted ?? null).toBeNull() + expect(created.roomId).not.toBe(attemptedRoomId) + expect(mocks.markHostedRoomLocallyDeleted).toHaveBeenCalledWith(attemptedRoomId) + + const fallback = mocks.notify.mock.calls.filter(([payload]) => + String(payload?.message || '').includes('Keep Desktop open') + ) + + expect(fallback).toHaveLength(1) + expect(fallback[0][0]).toEqual( + expect.objectContaining({ + kind: 'info', + message: "Studio can't keep this Group Chat running yet. Keep Desktop open." + }) + ) + }) + + it('does not create a competing Desktop room while remote cleanup is uncertain', async () => { + mocks.createAutonomousHostedGroupChat.mockRejectedValue( + Object.assign(new Error('cleanup pending'), { fallbackSafe: false }) + ) + const create = await renderSelectedGroup() + + await waitFor(() => expect(create.disabled).toBe(false)) + await act(async () => { + fireEvent.click(create) + }) + + const { $groupChats } = await import('./group-chat') + + await waitFor(() => expect(screen.getByText('Could not create the Group Chat. Try again.')).toBeTruthy()) + expect(Object.values($groupChats.get())).toHaveLength(0) + expect(mocks.notify).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/group-membership-controls.test.tsx b/apps/desktop/src/plugins/hermes-bots/group-membership-controls.test.tsx new file mode 100644 index 0000000000000..b89a4fd588b57 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/group-membership-controls.test.tsx @@ -0,0 +1,98 @@ +import type * as HermesSdk from '@hermes/plugin-sdk' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as DataModule from './data' +import { translateBots } from './i18n-test-helper' +import type { RosterRow } from './types' + +const mocks = vi.hoisted(() => ({ + notify: vi.fn(), + saveBotMeta: vi.fn(async () => undefined) +})) + +vi.mock('@hermes/plugin-sdk', async importOriginal => { + const original = await importOriginal() + + return { + ...original, + host: { + ...original.host, + notify: mocks.notify + }, + usePluginI18n: () => translateBots + } +}) + +vi.mock('./data', async importOriginal => { + const original = await importOriginal() + + return { + ...original, + saveBotMeta: mocks.saveBotMeta + } +}) + +const bot: RosterRow = { + name: 'research' +} + +beforeEach(async () => { + vi.clearAllMocks() + const [{ $botMeta }, { $groupChats }] = await Promise.all([import('./data'), import('./group-chat')]) + + $botMeta.set({ + research: { + groups: ['Hosted', 'Classic'] + } + }) + $groupChats.set({ + Hosted: { + continuityMode: 'gateway', + hosted: 'install:studio', + hostedConnectionId: 'host-a', + hostedEpoch: 1, + log: [], + members: [bot], + roomId: 'hosted-room', + watermarks: {} + }, + Classic: { + continuityMode: 'desktop', + log: [], + members: [bot], + roomId: 'classic-room', + watermarks: {} + } + }) +}) + +afterEach(() => { + cleanup() +}) + +describe('mixed Group Chat membership controls', () => { + it('keeps unrelated classic controls available while preserving the hosted membership', async () => { + const { GroupDialog } = await import('./create-dialog') + + render( undefined} />) + + const hosted = screen.getByText('Hosted').closest('label')!.querySelector('[role="checkbox"]') as HTMLButtonElement + + const classic = screen + .getByText('Classic') + .closest('label')! + .querySelector('[role="checkbox"]') as HTMLButtonElement + + expect(hosted.disabled).toBe(true) + expect(classic.disabled).toBe(false) + expect((screen.getByRole('button', { name: 'Leave other groups' }) as HTMLButtonElement).disabled).toBe(false) + + fireEvent.click(screen.getByRole('button', { name: 'Leave other groups' })) + + expect(mocks.saveBotMeta).toHaveBeenCalledWith(bot, { + groups: ['Hosted'], + group: 'Hosted' + }) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/group-membership.ts b/apps/desktop/src/plugins/hermes-bots/group-membership.ts index 8d8e8a00e4935..afe11dc96a373 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-membership.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-membership.ts @@ -248,7 +248,7 @@ export function groupChatMemberBots( * by friendly name against rows on the same connection. Unresolvable * descriptors return as-is — they stay visible-but-degraded ghosts and must * never be used as a `profile:` target. */ -function resolveLegacyMemberDescriptor(descriptor: RosterRow, roster: RosterRow[]): RosterRow { +export function resolveLegacyMemberDescriptor(descriptor: RosterRow, roster: RosterRow[]): RosterRow { const rows = roster || [] if (rows.some(bot => botRosterKey(bot) === botRosterKey(descriptor))) { @@ -296,6 +296,25 @@ function resolveLegacyMemberDescriptor(descriptor: RosterRow, roster: RosterRow[ return match || descriptor } +export function groupChatBotsFromDescriptors(descriptors: GroupMember[], roster: RosterRow[]): RosterRow[] { + const members: RosterRow[] = [] + const seen = new Set() + + for (const descriptor of Array.isArray(descriptors) ? descriptors : []) { + const resolved = resolveLegacyMemberDescriptor(descriptor, roster) + const key = botRosterKey(resolved) + + if (!key || seen.has(key)) { + continue + } + + seen.add(key) + members.push((roster || []).find(bot => !bot?.ghost && botRosterKey(bot) === key) || resolved) + } + + return members +} + /** Persist source-qualified identities for every selected member. The active * source's row may become remote after a connection switch, so retaining it * here is what keeps the same room intact across machines. */ diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index ff65ff2745f10..91cdbb554d552 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -3,6 +3,7 @@ * @mention parse, the round-robin driver, the #93129 member holds, the stop * path, and the user send that starts it all. */ +import { host } from '@hermes/plugin-sdk' import { botFriendlyNames, botHandle, clearBotAttention, mentionNameForms, noteBotAttention } from './data' import { recordGroupActivity } from './group-activity' @@ -14,6 +15,7 @@ import { GROUP_CHAT_MAX_CONTINUATIONS, GROUP_CHAT_MAX_MESSAGES, GROUP_CHAT_MAX_ROUNDS, + groupChatHostedGateway, groupSpeakerLabel, groupThreadOf, mintGroupThreadId, @@ -23,9 +25,26 @@ import { import type { GroupChatRoom, GroupHoldStamp } from './group-chat' import { durableGroupChatMembers, groupMemberKey } from './group-membership' import { harvestStrandedGroupReply, isGroupPassText, runGroupChatMemberTurn } from './group-turns' +import { + beginHostedRoomMutation, + groupChatContinuityReady, + hostedRoomMutationIsCurrent, + sendHostedGroupChat, + stopHostedGroupChat +} from './hosted-room-runtime' +import { botsText } from './i18n' import { requestForBot } from './routing' import type { Attachment, GroupMember, GroupMessage } from './types' +function hostedConnectionName(room: null | Partial | undefined) { + return room?.members?.find(member => member.connectionLabel)?.connectionLabel || botsText().group.thisHost +} + +interface SendGroupChatOptions { + entryId?: string + userName?: string +} + // ── group chats: bounded round-robin coordination over a shared room log ───── // // Behavioral model (clean-room): a group conversation is ONE ordered room log @@ -425,6 +444,86 @@ export function unaddressedGroupMentions(group: string, members: GroupMember[], * falls back to the room's durable roster so a two-arg call still works. */ export async function stopGroupThread(group: string, thread: null | string, members: GroupMember[] | null = null) { const room = $groupChats.get()[group] || {} + + if (groupChatHostedGateway(room)) { + if (room.hostedStatus?.state === 'stopping') { + return + } + + const connectionName = hostedConnectionName(room) + const roomId = String(room.roomId || '') + const generation = beginHostedRoomMutation(roomId) + + updateGroupChat( + group, + current => ({ + ...current, + running: true, + hostedStatus: { + state: 'stopping', + label: botsText().group.hostedStopping + } + }), + { + sync: false + } + ) + + try { + const acknowledged = await stopHostedGroupChat(group) + + if (!hostedRoomMutationIsCurrent(roomId, generation)) { + return + } + + updateGroupChat( + group, + current => ({ + ...current, + running: !acknowledged, + hostedStatus: { + state: acknowledged ? 'stopped' : 'queued', + label: acknowledged ? botsText().group.hostedStopped : botsText().group.hostedStopQueued(connectionName) + }, + continuityIssue: acknowledged ? null : botsText().group.hostedStopQueuedHint(connectionName) + }), + { + sync: false + } + ) + } catch { + if (!hostedRoomMutationIsCurrent(roomId, generation)) { + return + } + + updateGroupChat( + group, + current => ({ + ...current, + running: false, + hostedStatus: { + state: 'offline', + label: botsText().group.hostedUnavailable(connectionName) + }, + continuityIssue: botsText().group.hostedReconnectToStop(connectionName) + }), + { + sync: false + } + ) + } + + if (hostedRoomMutationIsCurrent(roomId, generation)) { + recordGroupActivity(group, { + kind: 'stopped', + member: 'You', + thread: thread || null + }) + } + + return + } + const roster = Array.isArray(members) && members.length ? members : room.members || [] const turnName = room.turn || null @@ -487,6 +586,38 @@ export async function stopGroupThread(group: string, thread: null | string, memb } } +/** Fence a classic-room turn after this Desktop loses its gateway command + * lease. Unlike a user Stop, this adds no durable holds, so the same command + * can be leased to the current owner and resumed idempotently. */ +export async function cancelGroupThreadForLeaseLoss( + group: string, + members: GroupMember[] | null = null +) { + const room = $groupChats.get()[group] || {} + const roster = Array.isArray(members) && members.length ? members : room.members || [] + const turnName = room.turn || null + + updateGroupChat(group, current => ({ + ...current, + epoch: (current.epoch || 0) + 1, + running: false, + turn: null + })) + + const onTurn = turnName ? roster.find(member => member?.name === turnName) : null + const sessionId = onTurn ? (room.sessions || {})[groupMemberKey(onTurn)] : null + + if (onTurn && sessionId) { + try { + await requestForBot(onTurn, 'session.interrupt', { + session_id: sessionId + }) + } catch { + /* the epoch fence still prevents stale room commits */ + } + } +} + /** Drive one bounded round-robin turn for ONE THREAD. Serial — one member at * a time. A newer user send bumps the room epoch; this loop notices at the * next member boundary, bails, and the newest send's own loop takes over. @@ -887,6 +1018,13 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], // the round cap ended the drive, not consensus. (#94478) exitKind = 'capped' } finally { + const externalIds = (Array.isArray(($groupChats.get()[group] || {}).log) + ? $groupChats.get()[group].log + : [] + ) + .filter(entry => entry?.external && groupThreadOf(entry) === thread && entry?.id) + .map(entry => String(entry.id)) + if (isCurrent()) { recordGroupActivity(group, { kind: exitKind, @@ -896,6 +1034,14 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], updateGroupChat(group, (r: GroupChatRoom) => { r.running = false r.turn = null + r.desktopCommandSettled = Object.fromEntries( + Object.entries({ + ...(r.desktopCommandSettled || {}), + ...Object.fromEntries(externalIds.map(id => [id, Date.now()])) + }) + .sort(([, left], [, right]) => Number(right) - Number(left)) + .slice(0, 128) + ) return r }) @@ -964,16 +1110,81 @@ export function sendToGroupChat( members: GroupMember[], text: string, thread?: null | string, - images?: Attachment[] + images?: Attachment[], + options: SendGroupChatOptions = {} ): null | string { const trimmed = String(text || '').trim() const attached = Array.isArray(images) ? images.filter((img: Attachment) => img && img.data) : [] + const roomBeforeSend = $groupChats.get()[group] + const hosted = groupChatHostedGateway(roomBeforeSend) + const connectionName = hostedConnectionName(roomBeforeSend) + const externalId = String(options.entryId || '').trim() + const userName = String(options.userName || 'You').trim().slice(0, 128) || 'You' if ((!trimmed && !attached.length) || !members.length) { return null } + if (hosted && roomBeforeSend?.hostedStatus?.state === 'deleted') { + return null + } + + if (!groupChatContinuityReady(roomBeforeSend)) { + updateGroupChat( + group, + current => ({ + ...current, + continuityIssue: botsText().group.hostedSyncing + }), + { sync: false } + ) + + return null + } + + if (hosted && attached.length) { + host.notify({ + kind: 'info', + message: botsText().group.hostedAttachmentsUnavailable + }) + + return null + } + const target = thread || mintGroupThreadId() + + if (externalId) { + const existing = (Array.isArray(roomBeforeSend?.log) ? roomBeforeSend.log : []).find(entry => entry?.id === externalId) + + if (existing) { + const existingThread = existing.thread || 'legacy' + + if (roomBeforeSend?.desktopCommandSettled?.[externalId] || roomBeforeSend?.running) { + return existingThread + } + + updateGroupChat(group, current => ({ + ...current, + members: durableGroupChatMembers(members), + epoch: (current.epoch || 0) + 1, + running: true + })) + recordGroupActivity(group, { + kind: 'queued', + member: userName, + thread: existingThread + }) + void runGroupChatRounds(group, members, existingThread).catch(() => { + updateGroupChat(group, current => ({ + ...current, + running: false + })) + }) + + return existingThread + } + } + $groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false @@ -991,13 +1202,91 @@ export function sendToGroupChat( group, { kind: 'user', - name: 'You' + name: userName }, trimmed, target, - attached + attached, + { + entryId: externalId, + external: Boolean(externalId) + } ) + if (!sent) { + return null + } + + if (hosted) { + const roomId = String(roomBeforeSend?.roomId || '') + const generation = beginHostedRoomMutation(roomId) + + updateGroupChat( + group, + (room: GroupChatRoom) => ({ + ...room, + running: true, + hostedStatus: { + state: 'sending', + label: botsText().group.hostedSending + } + }), + { + sync: false + } + ) + recordGroupActivity(group, { + kind: 'queued', + member: 'You', + thread: target + }) + void sendHostedGroupChat(group, sent, target) + .then(acknowledged => { + if (!hostedRoomMutationIsCurrent(roomId, generation)) { + return + } + + updateGroupChat( + group, + room => ({ + ...room, + running: true, + hostedStatus: { + state: acknowledged ? 'working' : 'queued', + label: acknowledged ? botsText().group.hostedWorking : botsText().group.hostedQueued(connectionName) + }, + continuityIssue: acknowledged ? null : botsText().group.hostedQueuedHint(connectionName) + }), + { + sync: false + } + ) + }) + .catch(() => { + if (!hostedRoomMutationIsCurrent(roomId, generation)) { + return + } + + updateGroupChat( + group, + room => ({ + ...room, + running: false, + hostedStatus: { + state: 'failed', + label: botsText().group.hostedNeedsAttention + }, + continuityIssue: botsText().group.hostedSendFailed(connectionName) + }), + { + sync: false + } + ) + }) + + return target + } + const wasRunning = ($groupChats.get()[group] || {}).running === true updateGroupChat(group, (room: GroupChatRoom) => { room.epoch = (room.epoch || 0) + 1 diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.test.ts new file mode 100644 index 0000000000000..dfb17107c3f0f --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.test.ts @@ -0,0 +1,542 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { pluginSdkMock, scriptedStorage } from './group-test-utils' + +const mocks = vi.hoisted(() => ({ + host: {} as Record, + requestProfile: vi.fn() +})) + +vi.mock('@hermes/plugin-sdk', async () => pluginSdkMock(mocks.host)) + +const reconnectOperation = () => ({ + operationId: 'reconnect:room-1:builder:grant', + setupId: 'reconnect:room-1:builder', + kind: 'peer-reconnect' as const, + connectionId: 'peer', + profile: 'builder', + grant: 'private-grant', + grantSha256: 'a'.repeat(64), + expectedGrantSha256: 'c'.repeat(64), + roomId: 'room-1', + cancelId: null, + homeConnectionId: 'home', + homeProfile: 'default', + memberId: 'builder', + targetUrl: 'https://peer.example.test:19445/p/builder', + catalog: { + catalog_digest: 'digest:peer', + installation_id: 'install:peer' + } +}) + +async function loadCleanup() { + vi.resetModules() + + return import('./hosted-room-cleanup') +} + +function expireCleanupOwners(durable: Map) { + const cleanup = durable.get('hosted-room-cleanup-v1') as { + operations?: Array> + } + + durable.set('hosted-room-cleanup-v1', { + version: 1, + operations: (cleanup?.operations || []).map(operation => ({ + ...operation, + ownerLeaseUntil: 0 + })) + }) +} + +function testLockManager() { + const held = new Set() + const tails = new Map>() + + return { + request(name: string, options: { ifAvailable?: boolean }, callback: (lock: null | object) => Promise | T) { + if (options.ifAvailable) { + if (held.has(name)) { + return Promise.resolve(callback(null)) + } + + held.add(name) + return Promise.resolve(callback({})).finally(() => held.delete(name)) + } + + const previous = tails.get(name) || Promise.resolve() + const result = previous.then(async () => { + held.add(name) + try { + return await callback({}) + } finally { + held.delete(name) + } + }) + + tails.set( + name, + result.then( + () => undefined, + () => undefined + ) + ) + return result + } + } +} + +beforeEach(() => { + vi.clearAllMocks() + Object.assign(mocks.host, { + profileRoutes: async () => [ + { connectionId: 'home', mode: 'remote', profile: 'default', targetProfile: 'default' }, + { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' } + ], + requestProfile: mocks.requestProfile + }) +}) + +describe('hosted Group Chat cleanup journal', () => { + it('preserves overlapping writes from separate Desktop windows', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const originalLocks = Object.getOwnPropertyDescriptor(globalThis.navigator, 'locks') + + Object.defineProperty(globalThis.navigator, 'locks', { + configurable: true, + value: testLockManager() + }) + + try { + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + + const second = await loadCleanup() + + await second.startHostedRoomCleanup(storage) + + await Promise.all([ + first.addHostedRoomCleanup(reconnectOperation()), + second.addHostedRoomCleanup({ + ...reconnectOperation(), + operationId: 'reconnect:room-2:builder:grant', + setupId: 'reconnect:room-2:builder', + roomId: 'room-2' + }) + ]) + + expect((durable.get('hosted-room-cleanup-v1') as { operations: Array<{ roomId: string }> }).operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ roomId: 'room-1' }), + expect.objectContaining({ roomId: 'room-2' }) + ]) + ) + await second.dispatchHostedRoomCleanup() + expect(mocks.requestProfile).not.toHaveBeenCalled() + + const persisted = durable.get('hosted-room-cleanup-v1') as { + operations: Array<{ ownerLeaseUntil: number; roomId: string }> + } + durable.set('hosted-room-cleanup-v1', { + version: 1, + operations: persisted.operations.map(operation => + operation.roomId === 'room-1' ? { ...operation, ownerLeaseUntil: 0 } : operation + ) + }) + mocks.requestProfile.mockResolvedValue({ registered: true }) + await second.dispatchHostedRoomCleanup() + expect(mocks.requestProfile).not.toHaveBeenCalled() + + first.stopHostedRoomCleanup() + await second.dispatchHostedRoomCleanup() + + expect(mocks.requestProfile).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'home' }), + 'groups.peer.register', + expect.objectContaining({ room_id: 'room-1' }) + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: Array<{ roomId: string }> }).operations).toEqual([ + expect.objectContaining({ roomId: 'room-2' }) + ]) + second.stopHostedRoomCleanup() + } finally { + if (originalLocks) { + Object.defineProperty(globalThis.navigator, 'locks', originalLocks) + } else { + Reflect.deleteProperty(globalThis.navigator, 'locks') + } + } + }) + + it('rejects a cleanup write that production storage cannot read back', async () => { + const cleanup = await loadCleanup() + const storage = { + get: vi.fn(async () => null), + set: vi.fn(() => undefined) + } + + await cleanup.startHostedRoomCleanup(storage as never) + await expect(cleanup.addHostedRoomCleanup(reconnectOperation())).rejects.toThrow( + 'did not persist Group Chat cleanup' + ) + expect(cleanup.$hostedRoomCleanup.get().operations).toEqual([]) + }) + + it('journals an invitation response that arrives after runtime stop', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const cleanup = await loadCleanup() + + await cleanup.startHostedRoomCleanup(storage) + cleanup.stopHostedRoomCleanup() + await cleanup.addHostedRoomCleanup(reconnectOperation()) + await cleanup.armHostedRoomCleanup(reconnectOperation().setupId) + await cleanup.dispatchHostedRoomCleanup() + + expect(mocks.requestProfile).not.toHaveBeenCalled() + expect((durable.get('hosted-room-cleanup-v1') as { operations: Array<{ armed: boolean }> }).operations).toEqual([ + expect.objectContaining({ armed: true }) + ]) + }) + + it('replays a reconnect registration after the Desktop dies post-invite', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'needs_reauthorization', grant_sha256: 'c'.repeat(64) }] + } + } + } + + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'home' }), + 'groups.peer.register', + expect.objectContaining({ + expected_grant_sha256: 'c'.repeat(64), + grant: 'private-grant', + member_id: 'builder', + room_id: 'room-1' + }) + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('replays owner-tagged cleanup after a same-process stop and start', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const cleanup = await loadCleanup() + + await cleanup.startHostedRoomCleanup(storage) + await cleanup.addHostedRoomCleanup(reconnectOperation()) + cleanup.stopHostedRoomCleanup() + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + + await cleanup.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'home' }), + 'groups.peer.register', + expect.anything() + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('keeps a matching route pending until registration is positively revalidated', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + throw new Error('response lost') + } + + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'ready', grant_sha256: 'a'.repeat(64) }] + } + } + } + + if (method === 'groups.peer.revoke_exact') { + throw new Error('must not revoke a committed grant') + } + + throw new Error(`unexpected method: ${method}`) + }) + + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).not.toHaveBeenCalledWith( + expect.anything(), + 'groups.peer.revoke_exact', + expect.anything() + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toHaveLength(1) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'ready', grant_sha256: 'a'.repeat(64) }] + } + } + } + + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + await restarted.dispatchHostedRoomCleanup() + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('keeps a failed revocation durable and retries it later', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup({ + operationId: 'revoke:room-1:builder', + setupId: 'revoke:room-1:builder', + kind: 'peer-revoke', + connectionId: 'peer', + profile: 'builder', + grant: 'private-grant', + roomId: null, + cancelId: null, + homeConnectionId: null, + homeProfile: null, + memberId: null, + targetUrl: null, + catalog: null + }) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockRejectedValueOnce(new Error('peer offline')) + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toHaveLength(1) + + mocks.requestProfile.mockResolvedValueOnce({ revoked: true }) + await restarted.dispatchHostedRoomCleanup() + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('keeps reconnect cleanup pending when home state omits driver status', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + throw new Error('home unreachable') + } + + if (method === 'groups.state') { + return { room: { room_id: 'room-1' } } + } + + if (method === 'groups.peer.revoke_exact') { + throw new Error('must not revoke an ambiguous grant') + } + + throw new Error(`unexpected method: ${method}`) + }) + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).not.toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'peer' }), + 'groups.peer.revoke_exact', + expect.anything() + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toHaveLength(1) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + await restarted.dispatchHostedRoomCleanup() + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('keeps a committed but unavailable route pending after a lost reply', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + throw new Error('response lost') + } + + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'unavailable', grant_sha256: 'a'.repeat(64) }] + } + } + } + + if (method === 'groups.peer.revoke_exact') { + throw new Error('must not revoke a transiently unavailable route') + } + + throw new Error(`unexpected method: ${method}`) + }) + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).not.toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'peer' }), + 'groups.peer.revoke_exact', + expect.anything() + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toHaveLength(1) + }) + + it('exact-revokes only the losing grant when another Desktop wins', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + throw new Error('response lost') + } + + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'ready', grant_sha256: 'b'.repeat(64) }] + } + } + } + + if (method === 'groups.peer.revoke_exact') { + return { revoked: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + + expect(mocks.requestProfile).not.toHaveBeenCalledWith(expect.anything(), 'groups.peer.register', expect.anything()) + expect(mocks.requestProfile).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'peer' }), + 'groups.peer.revoke_exact', + { grant: 'private-grant', profile: 'builder' } + ) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) + + it('retries a failed revoke after a definitive reconnect rejection', async () => { + const durable = new Map() + const storage = scriptedStorage(durable).storage + const first = await loadCleanup() + + await first.startHostedRoomCleanup(storage) + await first.addHostedRoomCleanup(reconnectOperation()) + first.stopHostedRoomCleanup() + expireCleanupOwners(durable) + + let revokeFails = true + mocks.requestProfile.mockImplementation(async (_route, method) => { + if (method === 'groups.peer.register') { + throw new Error('grant rejected') + } + + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'needs_reauthorization' }] + } + } + } + + if (method === 'groups.peer.revoke_exact') { + if (revokeFails) { + throw new Error('peer offline') + } + + return { revoked: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + const restarted = await loadCleanup() + + await restarted.startHostedRoomCleanup(storage) + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toHaveLength(1) + + revokeFails = false + await restarted.dispatchHostedRoomCleanup() + expect((durable.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.ts new file mode 100644 index 0000000000000..a943e70b7542e --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-cleanup.ts @@ -0,0 +1,602 @@ +/** Durable compensation journal for hosted Group Chat setup and reconnect. */ + +import { atom, host } from '@hermes/plugin-sdk' +import type { PluginContext } from '@hermes/plugin-sdk' + +import { botsText } from './i18n' +import type { ProfileRoute } from './types' + +export const HOSTED_ROOM_CLEANUP_KEY = 'hosted-room-cleanup-v1' +const HOSTED_ROOM_CLEANUP_LIMIT = 64 +const HOSTED_ROOM_CLEANUP_LOCK = 'hermes-bots-hosted-room-cleanup' +const HOSTED_ROOM_OWNER_LOCK_PREFIX = 'hermes-bots-hosted-room-owner:' +const HOSTED_ROOM_OWNER_LEASE_MS = 60_000 + +export interface HostedRoomCleanupOperation { + armed: boolean + cancelId?: null | string + catalog?: null | Record + connectionId: string + expectedGrantSha256?: null | string + grant?: null | string + grantSha256?: null | string + homeConnectionId?: null | string + homeProfile?: null | string + kind: 'home-disband' | 'peer-reconnect' | 'peer-revoke' | 'peer-revoke-exact' + memberId?: null | string + operationId: string + ownerId: string + ownerLeaseUntil: number + profile?: null | string + roomId?: null | string + setupId: string + targetUrl?: null | string +} + +export interface HostedRoomCleanup { + operations: HostedRoomCleanupOperation[] + version: 1 +} + +export const $hostedRoomCleanup = atom({ version: 1, operations: [] }) + +let cleanupOwnerId = '' +let cleanupStorage: null | PluginContext['storage'] = null +let cleanupDispatching = false +let cleanupDisposed = true +let cleanupGeneration = 0 +let cleanupMutationTail: Promise = Promise.resolve() +let cleanupOwnerLockRelease: null | (() => void) = null + +interface CleanupLockManager { + request( + name: string, + options: { ifAvailable?: boolean; mode: 'exclusive' }, + callback: (lock: null | object) => Promise | T + ): Promise +} + +function newCleanupOwnerId() { + return globalThis.crypto?.randomUUID?.() || `desktop-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +function record(value: unknown): null | Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +async function routeForReference(connectionId: string, profile = 'default') { + if (typeof host.profileRoutes !== 'function') { + return null + } + + const routes = await host.profileRoutes() + + return ((Array.isArray(routes) ? routes : []).find(route => { + const routeProfile = String(route?.targetProfile || route?.profile || '') + + return String(route?.connectionId || '') === connectionId && routeProfile === profile + }) || null) as ProfileRoute | null +} + +async function request(route: ProfileRoute, method: string, params: Record) { + if (!route?.connectionId || typeof host.requestProfile !== 'function') { + throw new Error(botsText().group.hostRouteMissing) + } + + return host.requestProfile(route, method, params) as Promise +} + +export function normalizeHostedRoomCleanup(value: unknown): HostedRoomCleanup { + const candidate = record(value) + const operations: HostedRoomCleanupOperation[] = [] + + for (const raw of Array.isArray(candidate?.operations) ? candidate.operations : []) { + const operation = record(raw) + const operationId = String(operation?.operationId || '') + const setupId = String(operation?.setupId || '') + const kind = String(operation?.kind || '') + const connectionId = String(operation?.connectionId || '') + + if ( + !operationId || + !setupId || + !connectionId || + !['home-disband', 'peer-reconnect', 'peer-revoke', 'peer-revoke-exact'].includes(kind) + ) { + continue + } + + if (kind === 'home-disband' && !String(operation?.roomId || '')) { + continue + } + + if ( + ['peer-revoke', 'peer-revoke-exact'].includes(kind) && + (!String(operation?.grant || '') || !String(operation?.profile || '')) + ) { + continue + } + + if ( + kind === 'peer-reconnect' && + (!String(operation?.grant || '') || + !/^[0-9a-f]{64}$/.test(String(operation?.grantSha256 || '')) || + !String(operation?.profile || '') || + !String(operation?.homeConnectionId || '') || + !String(operation?.homeProfile || '') || + !String(operation?.roomId || '') || + !String(operation?.memberId || '') || + !String(operation?.targetUrl || '') || + !record(operation?.catalog)) + ) { + continue + } + + const ownerId = String(operation?.ownerId || '') + const ownerLeaseUntil = Number(operation?.ownerLeaseUntil || 0) + + operations.push({ + armed: operation?.armed === true || !ownerId, + operationId, + setupId, + kind: kind as HostedRoomCleanupOperation['kind'], + connectionId, + ownerId, + ownerLeaseUntil: Number.isFinite(ownerLeaseUntil) && ownerLeaseUntil > 0 ? ownerLeaseUntil : 0, + roomId: ['home-disband', 'peer-reconnect'].includes(kind) ? String(operation?.roomId || '') : null, + cancelId: + kind === 'home-disband' ? String(operation?.cancelId || `rollback-${String(operation?.roomId || '')}`) : null, + profile: kind === 'home-disband' ? null : String(operation?.profile || ''), + grant: kind === 'home-disband' ? null : String(operation?.grant || ''), + grantSha256: kind === 'peer-reconnect' ? String(operation?.grantSha256 || '') : null, + expectedGrantSha256: + kind === 'peer-reconnect' && /^[0-9a-f]{64}$/.test(String(operation?.expectedGrantSha256 || '')) + ? String(operation?.expectedGrantSha256) + : null, + homeConnectionId: kind === 'peer-reconnect' ? String(operation?.homeConnectionId || '') : null, + homeProfile: kind === 'peer-reconnect' ? String(operation?.homeProfile || '') : null, + memberId: kind === 'peer-reconnect' ? String(operation?.memberId || '') : null, + targetUrl: kind === 'peer-reconnect' ? String(operation?.targetUrl || '') : null, + catalog: kind === 'peer-reconnect' ? record(operation?.catalog) : null + }) + } + + return { + version: 1, + operations: operations.slice(-HOSTED_ROOM_CLEANUP_LIMIT) + } +} + +function processCleanupLock(callback: () => Promise) { + const result = cleanupMutationTail.then(callback, callback) + + cleanupMutationTail = result.then( + () => undefined, + () => undefined + ) + + return result +} + +function cleanupLockManager() { + return (globalThis.navigator as (Navigator & { locks?: CleanupLockManager }) | undefined)?.locks +} + +async function withCleanupLock(callback: () => Promise) { + const locks = cleanupLockManager() + + return locks?.request + ? locks.request(HOSTED_ROOM_CLEANUP_LOCK, { mode: 'exclusive' }, callback) + : processCleanupLock(callback) +} + +async function holdCleanupOwnerLock(ownerId: string) { + cleanupOwnerLockRelease?.() + cleanupOwnerLockRelease = null + const locks = cleanupLockManager() + + if (!locks?.request) { + return + } + + let entered: () => void = () => undefined + let release: () => void = () => undefined + const acquired = new Promise(resolve => { + entered = resolve + }) + const held = new Promise(resolve => { + release = resolve + }) + + cleanupOwnerLockRelease = release + void locks + .request(`${HOSTED_ROOM_OWNER_LOCK_PREFIX}${ownerId}`, { mode: 'exclusive' }, async () => { + entered() + await held + }) + .catch(() => entered()) + await acquired +} + +async function cleanupOwnerIsLive(operation: HostedRoomCleanupOperation) { + if (!operation.ownerId) { + return false + } + + if (operation.ownerId === cleanupOwnerId) { + return !operation.armed + } + + const locks = cleanupLockManager() + + if (!locks?.request) { + return operation.ownerLeaseUntil > Date.now() + } + + try { + let live = true + + await locks.request( + `${HOSTED_ROOM_OWNER_LOCK_PREFIX}${operation.ownerId}`, + { ifAvailable: true, mode: 'exclusive' }, + lock => { + live = lock === null + } + ) + + return live + } catch { + return operation.ownerLeaseUntil > Date.now() + } +} + +async function readPersistedCleanup() { + if (!cleanupStorage?.get) { + throw new Error('Desktop storage is unavailable, so Group Chat setup cannot be secured.') + } + + return normalizeHostedRoomCleanup(await cleanupStorage.get(HOSTED_ROOM_CLEANUP_KEY, null)) +} + +async function replaceCleanup(previous: HostedRoomCleanup, next: HostedRoomCleanup) { + if (!cleanupStorage?.set || !cleanupStorage?.get) { + throw new Error('Desktop storage is unavailable, so Group Chat setup cannot be secured.') + } + + $hostedRoomCleanup.set(next) + + try { + await cleanupStorage.set(HOSTED_ROOM_CLEANUP_KEY, next) + const persisted = normalizeHostedRoomCleanup(await cleanupStorage.get(HOSTED_ROOM_CLEANUP_KEY, null)) + + if (JSON.stringify(persisted) !== JSON.stringify(next)) { + throw new Error('Desktop storage did not persist Group Chat cleanup.') + } + } catch (error) { + $hostedRoomCleanup.set(previous) + throw error + } +} + +async function mutateCleanup(update: (current: HostedRoomCleanup) => HostedRoomCleanup) { + return withCleanupLock(async () => { + const current = await readPersistedCleanup() + const next = normalizeHostedRoomCleanup(update(current)) + + if (JSON.stringify(current) === JSON.stringify(next)) { + $hostedRoomCleanup.set(current) + return current + } + + await replaceCleanup(current, next) + + return next + }) +} + +export async function addHostedRoomCleanup( + operation: Omit +) { + await mutateCleanup(current => { + const next = normalizeHostedRoomCleanup({ + version: 1, + operations: [ + ...current.operations.filter(entry => entry.operationId !== operation.operationId), + { + ...operation, + armed: false, + ownerId: cleanupOwnerId, + ownerLeaseUntil: Date.now() + HOSTED_ROOM_OWNER_LEASE_MS + } + ] + }) + + if (next.operations.length >= HOSTED_ROOM_CLEANUP_LIMIT && current.operations.length >= HOSTED_ROOM_CLEANUP_LIMIT) { + throw new Error('Group Chat cleanup is pending. Reconnect the affected devices before creating another.') + } + + return next + }) +} + +export async function releaseHostedRoomCleanup(setupId: string) { + await mutateCleanup(current => ({ + version: 1, + operations: current.operations.filter(operation => operation.setupId !== setupId) + })) +} + +export async function armHostedRoomCleanup(setupId: string) { + await mutateCleanup(current => ({ + version: 1, + operations: current.operations.map(operation => + operation.setupId === setupId + ? { + ...operation, + armed: true, + ownerId: '', + ownerLeaseUntil: 0 + } + : operation + ) + })) +} + +export function hostedRoomCleanupPending(setupId: string) { + return normalizeHostedRoomCleanup($hostedRoomCleanup.get()).operations.some( + operation => operation.setupId === setupId + ) +} + +function homeDisbandAlreadySettled(operation: HostedRoomCleanupOperation, error: unknown) { + const candidate = record(error) + const inner = record(candidate?.error) + const code = Number(candidate?.code ?? inner?.code) + const message = String(candidate?.message || inner?.message || error || '') + + return operation.kind === 'home-disband' && code === 4113 && /hosted room not found|already disbanded/i.test(message) +} + +async function peerRouteStatus(operation: HostedRoomCleanupOperation, homeRoute: ProfileRoute) { + const state = record( + await request>(homeRoute, 'groups.state', { + room_id: operation.roomId + }) + ) + const driver = record(state?.driver_status) + if (!driver || !Array.isArray(driver.peer_routes)) { + return 'unknown' as const + } + const route = driver.peer_routes + .map(record) + .find(candidate => String(candidate?.member_id || '') === String(operation.memberId || '')) + + const status = String(route?.status || '') + const grantSha256 = String(route?.grant_sha256 || '') + const sameGrant = grantSha256 && grantSha256 === String(operation.grantSha256 || '') + const expectedGrant = grantSha256 && grantSha256 === String(operation.expectedGrantSha256 || '') + + if (status === 'needs_reauthorization' && sameGrant) { + return 'nonready' as const + } + + if (sameGrant) { + return 'matching' as const + } + + if (expectedGrant || (!grantSha256 && !operation.expectedGrantSha256)) { + return 'expected' as const + } + + if (grantSha256) { + return 'conflict' as const + } + + if (status === 'needs_reauthorization') { + return 'nonready' as const + } + + return 'unknown' as const +} + +async function settlePeerReconnect(operation: HostedRoomCleanupOperation) { + const homeRoute = await routeForReference( + String(operation.homeConnectionId || ''), + String(operation.homeProfile || 'default') + ) + + if (!homeRoute) { + return 'pending' as const + } + + try { + if (['conflict', 'nonready'].includes(await peerRouteStatus(operation, homeRoute))) { + return 'revoke' as const + } + } catch { + /* registration remains the only safe settlement proof */ + } + + try { + await request(homeRoute, 'groups.peer.register', { + room_id: operation.roomId, + member_id: operation.memberId, + target_url: operation.targetUrl, + target_profile: operation.profile, + grant: operation.grant, + catalog: operation.catalog, + expected_grant_sha256: operation.expectedGrantSha256 || '' + }) + + return 'settled' as const + } catch { + try { + return ['conflict', 'nonready'].includes(await peerRouteStatus(operation, homeRoute)) + ? ('revoke' as const) + : ('pending' as const) + } catch { + return 'pending' as const + } + } +} + +async function runCleanup(operation: HostedRoomCleanupOperation) { + if (operation.kind === 'peer-reconnect') { + const outcome = await settlePeerReconnect(operation) + + if (outcome === 'settled') { + return true + } + + if (outcome === 'pending') { + return false + } + } + + const profile = operation.kind === 'home-disband' ? 'default' : String(operation.profile || '') + const route = await routeForReference(operation.connectionId, profile) + + if (!route) { + return false + } + + try { + if (operation.kind === 'home-disband') { + await request(route, 'groups.disband', { + room_id: operation.roomId, + cancel_id: operation.cancelId + }) + } else { + await request(route, operation.kind === 'peer-revoke' ? 'groups.peer.revoke' : 'groups.peer.revoke_exact', { + grant: operation.grant, + profile: operation.profile + }) + } + + return true + } catch (error) { + return homeDisbandAlreadySettled(operation, error) + } +} + +export async function dispatchHostedRoomCleanup() { + if (cleanupDispatching || cleanupDisposed) { + return + } + + cleanupDispatching = true + + try { + const snapshot = await mutateCleanup(current => ({ + version: 1, + operations: current.operations.map(operation => + operation.ownerId === cleanupOwnerId && !operation.armed + ? { + ...operation, + ownerLeaseUntil: Date.now() + HOSTED_ROOM_OWNER_LEASE_MS + } + : operation + ) + })) + + for (const operation of snapshot.operations) { + if (await cleanupOwnerIsLive(operation)) { + continue + } + + let claimed: HostedRoomCleanupOperation | null = null + + await mutateCleanup(current => ({ + version: 1, + operations: current.operations.map(entry => { + if (JSON.stringify(entry) !== JSON.stringify(operation)) { + return entry + } + + claimed = { + ...entry, + armed: true, + ownerId: cleanupOwnerId, + ownerLeaseUntil: Date.now() + HOSTED_ROOM_OWNER_LEASE_MS + } + + return claimed + }) + })) + + if (!claimed || !(await runCleanup(claimed))) { + continue + } + + await mutateCleanup(latest => ({ + version: 1, + operations: latest.operations.filter( + entry => entry.operationId !== claimed?.operationId || JSON.stringify(entry) !== JSON.stringify(claimed) + ) + })) + } + } finally { + cleanupDispatching = false + } +} + +export async function startHostedRoomCleanup(storage: PluginContext['storage']) { + const generation = ++cleanupGeneration + const previousOwnerId = cleanupOwnerId + cleanupOwnerId = newCleanupOwnerId() + cleanupStorage = storage + cleanupDisposed = false + await holdCleanupOwnerLock(cleanupOwnerId) + + await withCleanupLock(async () => { + let persisted: unknown = null + + try { + persisted = await storage?.get?.(HOSTED_ROOM_CLEANUP_KEY, null) + } catch { + /* empty cleanup is the safe fallback */ + } + + if (!cleanupDisposed && generation === cleanupGeneration) { + const current = normalizeHostedRoomCleanup(persisted) + const next = normalizeHostedRoomCleanup({ + version: 1, + operations: current.operations.map(operation => + previousOwnerId && operation.ownerId === previousOwnerId + ? { + ...operation, + armed: true, + ownerId: '', + ownerLeaseUntil: 0 + } + : operation + ) + }) + + await replaceCleanup(current, next) + } + }) + + if (cleanupDisposed || generation !== cleanupGeneration) { + return + } + + await dispatchHostedRoomCleanup().catch(() => undefined) +} + +export function stopHostedRoomCleanup() { + cleanupGeneration += 1 + cleanupDisposed = true + cleanupOwnerLockRelease?.() + cleanupOwnerLockRelease = null +} + +export function resetHostedRoomCleanupForTests() { + stopHostedRoomCleanup() + cleanupDispatching = false + cleanupOwnerId = '' + cleanupStorage = null + $hostedRoomCleanup.set({ version: 1, operations: [] }) +} diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts new file mode 100644 index 0000000000000..c9a90fe53af36 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts @@ -0,0 +1,525 @@ +import { describe, expect, it } from 'vitest' + +import { + classifyHostedRoomCapability, + createHostedRoomOutbox, + createHostedRoomReplayState, + deriveFriendlyHostedRoomStatus, + isHostedRoomContinuityEligible, + profileScopedRoomLinkEndpoint, + reduceHostedRoomEvents, + reduceHostedRoomOutbox, + replayHostedRoomPages, + resolveAutonomousRoomPlan, + resolveSingleGatewayRoute +} from './hosted-room-client' + +function event( + seq: number, + eventId: string, + kind: string, + payload: Record = {}, + actor: Record = { + kind: 'gateway', + id: 'install:home' + } +) { + return { + room_id: 'room-1', + seq, + event_id: eventId, + kind, + actor, + payload, + created_at: seq + } +} + +describe('hosted Group Chat capability negotiation', () => { + it('distinguishes an old gateway from a transient outage and a persistent driver', () => { + const missing = Object.assign(new Error('JSON-RPC -32601: method not found'), { + code: -32601 + }) + + const old = classifyHostedRoomCapability( + { + ok: false, + error: missing + }, + { + connectionId: 'gateway-a' + } + ) + + const offline = classifyHostedRoomCapability(new Error('socket closed during reconnect')) + + const capable = classifyHostedRoomCapability( + { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home', + features: ['peer_route_grant_fingerprint', 'reciprocal_room_control'], + methods: ['groups.peer.revoke_exact'], + max_log_limit: 250 + }, + { + connectionId: 'gateway-a' + } + ) + + expect(old).toMatchObject({ + kind: 'unsupported', + reason: 'old-gateway', + connectionId: 'gateway-a' + }) + expect(offline.kind).toBe('transient-failure') + expect(capable).toMatchObject({ + kind: 'driver-capable', + authorityId: 'install:home', + exactPeerGrantRevoke: true, + persistentProcess: true, + routeGrantFingerprint: true, + reciprocalControl: true, + maxLogLimit: 250 + }) + expect(isHostedRoomContinuityEligible(capable)).toBe(true) + expect( + classifyHostedRoomCapability({ + driver: true, + persistent_process: true, + authority_gateway_id: 'install:older' + }).exactPeerGrantRevoke + ).toBe(false) + expect( + classifyHostedRoomCapability({ + driver: true, + persistent_process: true, + authority_gateway_id: 'install:older' + }).routeGrantFingerprint + ).toBe(false) + expect( + isHostedRoomContinuityEligible({ + driver: true, + persistent_process: false + }) + ).toBe(false) + }) + + it('offers hosted continuity only when every member resolves to one gateway', () => { + const same = resolveSingleGatewayRoute( + [ + { + name: 'research', + connectionId: 'gateway-a', + sourceScoped: true + }, + { + name: 'builder', + route: { + connectionId: 'gateway-a', + mode: 'remote', + profile: 'builder', + targetProfile: 'builder' + }, + remoteSource: true + } + ], + { + activeConnectionId: 'local' + } + ) + + const mixed = resolveSingleGatewayRoute([ + { + name: 'research', + connectionId: 'gateway-a', + sourceScoped: true + }, + { + name: 'builder', + connectionId: 'gateway-b', + sourceScoped: true + } + ]) + + const unresolved = resolveSingleGatewayRoute( + [ + { + name: 'research' + }, + { + name: 'missing', + sourceScoped: true + } + ], + { + activeConnectionId: 'local' + } + ) + + expect(same).toMatchObject({ + kind: 'single-gateway', + connectionId: 'gateway-a' + }) + expect(mixed).toMatchObject({ + kind: 'unsupported', + reason: 'cross-gateway' + }) + expect(unresolved.reason).toBe('unresolved-member-route') + }) + + it('plans a direct multi-host Group Chat only from verified v2 catalogs', () => { + const capability = (connectionId: string, installationId: string) => + classifyHostedRoomCapability( + { + authority_gateway_id: installationId, + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { + available: true, + url: `https://${connectionId}.example.test:19445` + }, + catalog: { + attachments: false, + catalog_digest: `digest-${connectionId}`, + installation_id: installationId, + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + }, + { + connectionId + } + ) + + const capabilities = { + 'host-a': capability('host-a', 'install:a'), + 'host-b': capability('host-b', 'install:b') + } + + const plan = resolveAutonomousRoomPlan( + [ + { connectionId: 'host-a', name: 'research', sourceScoped: true }, + { connectionId: 'host-b', name: 'builder', sourceScoped: true } + ], + { + activeConnectionId: 'host-a', + capabilities + } + ) + + expect(plan).toMatchObject({ + connectionId: 'host-a', + homeConnectionId: 'host-a', + kind: 'multi-gateway', + remoteConnectionIds: ['host-b'] + }) + expect(capabilities['host-b'].roomLink?.catalog?.attachments).toBe(false) + + const incompatible = { + ...capabilities, + 'host-b': classifyHostedRoomCapability( + { + authority_gateway_id: 'install:b', + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://host-b.example.test' }, + catalog: { + catalog_digest: 'digest-b', + installation_id: 'install:b', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [1], + text: true + } + } + }, + { connectionId: 'host-b' } + ) + } + + expect( + resolveAutonomousRoomPlan( + [ + { connectionId: 'host-a', name: 'research', sourceScoped: true }, + { connectionId: 'host-b', name: 'builder', sourceScoped: true } + ], + { activeConnectionId: 'host-a', capabilities: incompatible } + ) + ).toMatchObject({ + kind: 'unsupported', + reason: 'remote-needs-setup', + unavailableConnectionId: 'host-b' + }) + }) + + it('scopes one advertised endpoint to the selected Bot profile', () => { + expect(profileScopedRoomLinkEndpoint('https://peer.example.test/hermes/', 'research lead')).toBe( + 'https://peer.example.test/hermes/p/research%20lead' + ) + expect(profileScopedRoomLinkEndpoint('https://peer.example.test/hermes/p/other', 'research lead')).toBeNull() + expect(profileScopedRoomLinkEndpoint('https://peer.example.test/hermes/', 'default')).toBe( + 'https://peer.example.test/hermes' + ) + }) +}) + +describe('hosted Group Chat replay', () => { + it('normalizes gateway epoch seconds before rendering relative time', () => { + const seconds = 1_787_968_060.355 + + const replayed = reduceHostedRoomEvents(createHostedRoomReplayState({ roomId: 'room-1' }), [ + { + ...event(1, 'message-1', 'message.user', { text: 'Hello' }), + created_at: seconds + } + ]) + + expect(replayed.messages[0].at).toBe(seconds * 1000) + }) + + it('orders, deduplicates, and advances across unknown event kinds without applying them', () => { + const initial = createHostedRoomReplayState({ + roomId: 'room-1', + name: 'Release', + authorityId: 'install:home', + connectionId: 'gateway-a' + }) + + const user = event( + 1, + 'message-1', + 'message.user', + { + text: 'Start the review', + thread_id: 'thread-1' + }, + { + kind: 'user', + id: 'desktop' + } + ) + + const unknown = event(2, 'future-1', 'future.room.signal', { + destructive: true + }) + + const member = event( + 3, + 'message-2', + 'message.member', + { + text: 'Review complete', + thread_id: 'thread-1' + }, + { + kind: 'member', + id: 'research', + display_name: 'Research' + } + ) + + const replayed = reduceHostedRoomEvents(initial, [member, unknown, user, user]) + + expect(replayed.cursor).toBe(3) + expect(replayed.messages.map(message => [message.seq, message.eventId, message.text])).toEqual([ + [1, 'message-1', 'Start the review'], + [3, 'message-2', 'Review complete'] + ]) + expect(replayed.timeline.map(entry => entry.eventId)).toEqual(['message-1', 'message-2']) + expect(reduceHostedRoomEvents(replayed, [member, user]).messages).toHaveLength(2) + }) + + it('buffers gaps and never advances past missing history', () => { + const initial = createHostedRoomReplayState({ + roomId: 'room-1' + }) + + const later = event(2, 'message-2', 'message.member', { text: 'Done' }) + + const gapped = reduceHostedRoomEvents(initial, [later]) + + expect(gapped.cursor).toBe(0) + expect(gapped.messages).toEqual([]) + expect(gapped.pendingEvents).toHaveLength(1) + + const complete = reduceHostedRoomEvents(gapped, [event(1, 'message-1', 'message.user', { text: 'Go' })]) + + expect(complete.cursor).toBe(2) + expect(complete.messages.map(message => message.text)).toEqual(['Go', 'Done']) + }) + + it('pages from the persisted cursor and stops safely on a stalled gap', async () => { + const pages = [ + { + events: [event(1, 'message-1', 'message.user', { text: 'Go' })], + latest_seq: 3, + has_more: true + }, + { + events: [event(3, 'message-3', 'message.member', { text: 'Done' })], + latest_seq: 3, + has_more: false + } + ] + + const replayed = await replayHostedRoomPages({ + state: createHostedRoomReplayState({ + roomId: 'room-1' + }), + fetchPage: async () => pages.shift() + }) + + expect(replayed).toMatchObject({ + complete: false, + reason: 'stalled', + pages: 2 + }) + expect(replayed.state.cursor).toBe(1) + expect(replayed.state.pendingEvents).toHaveLength(1) + }) + + it('derives short status copy without reflecting raw provider details', () => { + const state = reduceHostedRoomEvents( + createHostedRoomReplayState({ + roomId: 'room-1', + connectionId: 'gateway-a' + }), + [ + event(1, 'failed-1', 'turn.failed', { + member_display_name: 'Builder', + reason_code: 'provider_auth_or_access', + raw_error: 'secret upstream payload' + }) + ] + ) + + const friendly = deriveFriendlyHostedRoomStatus(state) + + expect(friendly).toMatchObject({ + kind: 'needs-attention', + member: 'Builder', + reasonCode: 'provider_auth_or_access' + }) + expect(JSON.stringify(friendly)).not.toContain('secret upstream payload') + }) +}) + +describe('hosted Group Chat command outbox', () => { + const command = { + commandId: 'command-1', + kind: 'send' as const, + roomId: 'room-1', + authorityId: 'install:home', + connectionId: 'gateway-a', + payload: { + text: 'Hello', + thread_id: 'thread-1' + } + } + + it('returns an interrupted in-flight command to pending with the same idempotency key', () => { + const enqueued = reduceHostedRoomOutbox(createHostedRoomOutbox(), { + type: 'enqueue', + command + }) + + const inFlight = reduceHostedRoomOutbox(enqueued, { + type: 'dispatch', + commandId: command.commandId + }) + + const restored = createHostedRoomOutbox(inFlight) + + expect(restored.commands).toEqual([ + expect.objectContaining({ + commandId: command.commandId, + status: 'pending', + attempts: 1 + }) + ]) + }) + + it('deduplicates identical commands, rejects conflicting reuse, and drops acknowledged work', () => { + const once = reduceHostedRoomOutbox(createHostedRoomOutbox(), { + type: 'enqueue', + command + }) + + const twice = reduceHostedRoomOutbox(once, { + type: 'enqueue', + command: { + ...command, + payload: { + thread_id: 'thread-1', + text: 'Hello' + } + } + }) + + expect(twice.commands).toHaveLength(1) + expect(() => + reduceHostedRoomOutbox(twice, { + type: 'enqueue', + command: { + ...command, + payload: { + text: 'Different' + } + } + }) + ).toThrow(/different content/) + expect(() => + reduceHostedRoomOutbox(twice, { + type: 'enqueue', + command: { + ...command, + commandId: 'raw-file-command', + payload: { + content_base64: 'not-allowed-in-stage-1' + } + } + }) + ).toThrow(/cannot carry raw attachment/) + expect( + reduceHostedRoomOutbox(twice, { + type: 'acknowledge', + commandId: command.commandId + }).commands + ).toEqual([]) + }) + + it('fails closed before an offline device can grow the outbox without bound', () => { + let outbox = createHostedRoomOutbox() + + for (let index = 0; index < 256; index += 1) { + outbox = reduceHostedRoomOutbox(outbox, { + type: 'enqueue', + command: { + ...command, + commandId: `command-${index}`, + payload: { + text: `Message ${index}` + } + } + }) + } + + expect(() => + reduceHostedRoomOutbox(outbox, { + type: 'enqueue', + command: { + ...command, + commandId: 'command-overflow' + } + }) + ).toThrow(/waiting to sync/) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts new file mode 100644 index 0000000000000..d73b5eba9dde5 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts @@ -0,0 +1,1243 @@ +/** + * Pure client-side contracts for gateway-hosted Group Chats. + * + * This module never talks to a gateway or mutates a room atom. It classifies + * capability probes, validates the same-gateway boundary, reduces the + * monotonic event log, and owns the serializable command outbox. Keeping those + * transitions pure makes reconnect/relaunch behavior testable independently + * from React and the plugin lifecycle. + */ + +import type { GroupMember, GroupMessageAuthor } from './types' + +const MIN_ROOM_MEMBERS = 2 +const MAX_ROOM_MEMBERS = 6 +const MAX_REPLAY_PAGE_SIZE = 500 +const MAX_REPLAY_PAGES = 100 +const FORBIDDEN_TRANSPORT_FIELD_TOKENS = new Set(['base64', 'byte', 'bytes', 'data', 'path', 'paths']) +export const ROOM_LINK_PROTOCOL_VERSION = 2 + +export interface HostedRoomClientLimitations { + attachments: boolean + automaticFailover: boolean + crossGatewayMembers: boolean +} + +export const HOSTED_ROOM_CLIENT_LIMITATIONS: HostedRoomClientLimitations = Object.freeze({ + attachments: false, + automaticFailover: false, + crossGatewayMembers: true +}) + +const MAX_HOSTED_ROOM_OUTBOX_COMMANDS = 256 + +export type HostedRoomCapabilityKind = 'driver-capable' | 'transient-failure' | 'unsupported' + +export interface HostedRoomCapability { + authorityId: null | string + connectionId: null | string + exactPeerGrantRevoke: boolean + kind: HostedRoomCapabilityKind + limits: typeof HOSTED_ROOM_CLIENT_LIMITATIONS + maxLogLimit?: number + persistentProcess: boolean | null + routeGrantFingerprint: boolean + reciprocalControl: boolean + reason: null | string + roomLink: null | RoomLinkCapability +} + +export interface RoomLinkCapability { + catalog: null | { + attachments: boolean + digest: null | string + installationId: null | string + linkModes: string[] + persistentProcess: boolean + protocolVersions: number[] + text: boolean + } + enabled: boolean + endpoint: null | string + endpointReason: null | string + profile: null | string + reason: null | string +} + +export interface AutonomousRoomPlan extends HostedRoomRouteResolution { + homeConnectionId: null | string + remoteConnectionIds: string[] + unavailableConnectionId?: string +} + +export interface HostedRoomRouteResolution { + connectionId: null | string + kind: 'multi-gateway' | 'single-gateway' | 'unsupported' + limits: typeof HOSTED_ROOM_CLIENT_LIMITATIONS + memberConnectionIds: Array + reason: null | string +} + +export interface HostedRoomEvent { + actor: Record + createdAt: number + eventId: string + kind: string + payload: Record + roomId: null | string + seq: number +} + +export interface HostedReplayMessage { + at: number + eventId: string + from: GroupMessageAuthor + seq: number + text: string + thread: string +} + +export interface HostedRoomActivity { + at: number + eventId: string + kind: string + member: string + reasonCode: null | string + seq: number +} + +export interface HostedRoomReplayState { + activity: HostedRoomActivity[] + authorityEpoch: null | number + authorityId: null | string + conflicts: Array<{ eventId: string; seq: number }> + connectionId: null | string + cursor: number + deleted: boolean + lastStatusEvent: HostedRoomEvent | null + latestSeq: number + members: Array> + messages: HostedReplayMessage[] + name: string + pendingEvents: HostedRoomEvent[] + roomId: null | string + timeline: Array<{ eventId: string; kind: string; seq: number }> +} + +export interface FriendlyHostedRoomStatus { + canRetry?: boolean + canStop?: boolean + kind: string + member?: null | string + reasonCode?: null | string +} + +export type HostedRoomCommandKind = 'create' | 'disband' | 'rename' | 'retry' | 'send' | 'stop' +export type HostedRoomCommandStatus = 'failed' | 'in-flight' | 'pending' + +export interface HostedRoomCommand { + attempts: number + authorityId: null | string + commandId: string + connectionId: string + failureCode: null | string + kind: HostedRoomCommandKind + payload: Record + roomId: string + status: HostedRoomCommandStatus +} + +export interface HostedRoomOutbox { + commands: HostedRoomCommand[] + version: 1 +} + +export type HostedRoomOutboxAction = + | { command: Partial; type: 'enqueue' } + | { commandId: string; type: 'acknowledge' | 'dispatch' | 'retry' | 'transient-failure' } + | { commandId: string; failureCode?: string; type: 'terminal-failure' } + +const STATUS_EVENT_KINDS = new Set([ + 'authority.lost', + 'member.unavailable', + 'room.activity', + 'turn.cancelled', + 'turn.deferred', + 'turn.failed', + 'turn.reassigned', + 'turn.settled', + 'turn.started' +]) + +const KNOWN_EVENT_KINDS = new Set([ + 'authority.claimed', + 'authority.lost', + 'member.unavailable', + 'message.member', + 'message.user', + 'room.activity', + 'room.created', + 'room.disbanded', + 'room.members_changed', + 'room.renamed', + 'turn.cancelled', + 'turn.deferred', + 'turn.failed', + 'turn.reassigned', + 'turn.settled', + 'turn.started' +]) + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null +} + +function text(value: unknown): null | string { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function nonNegativeInteger(value: unknown, fallback = 0): number { + const number = Number(value) + + return Number.isSafeInteger(number) && number >= 0 ? number : fallback +} + +function positiveInteger(value: unknown, fallback: null | number = null): null | number { + const number = Number(value) + + return Number.isSafeInteger(number) && number > 0 ? number : fallback +} + +function timestampMilliseconds(value: unknown) { + const number = Number(value) + + if (!Number.isFinite(number) || number <= 0) { + return 0 + } + + return number < 1_000_000_000_000 ? number * 1000 : number +} + +function errorCode(error: unknown): unknown { + const outer = record(error) + const inner = record(outer?.error) + + return outer?.code ?? inner?.code ?? null +} + +function errorMessage(error: unknown): string { + const outer = record(error) + const inner = record(outer?.error) + + return String(outer?.message || inner?.message || error || '') +} + +function isMissingCapabilityMethod(error: unknown): boolean { + return ( + errorCode(error) === -32601 || + /method not found|-32601|unknown method|no such method|no handler for|unsupported rpc/i.test(errorMessage(error)) + ) +} + +function capabilityResult(probe: unknown): Record | null { + const candidate = record(probe) + + if (!candidate) { + return null + } + + if (candidate.ok === true) { + return record(candidate.result) + } + + if ( + !Object.prototype.hasOwnProperty.call(candidate, 'ok') && + !Object.prototype.hasOwnProperty.call(candidate, 'error') + ) { + return candidate + } + + return null +} + +function roomLinkCapability(value: unknown): null | RoomLinkCapability { + const candidate = record(value) + + if (!candidate) { + return null + } + + const catalog = record(candidate.catalog) + const endpoint = record(candidate.endpoint) + + return { + enabled: candidate.enabled === true, + endpoint: endpoint?.available === true ? text(endpoint.url) : null, + endpointReason: endpoint?.available === false ? text(endpoint.reason) : null, + reason: text(candidate.reason), + profile: text(candidate.profile), + catalog: catalog + ? { + installationId: text(catalog.installation_id), + digest: text(catalog.catalog_digest), + persistentProcess: catalog.persistent_process === true, + text: catalog.text === true, + attachments: catalog.attachments === true, + linkModes: Array.isArray(catalog.link_modes) ? catalog.link_modes.map(String).filter(Boolean) : [], + protocolVersions: Array.isArray(catalog.protocol_versions) + ? catalog.protocol_versions.map(Number).filter(Number.isSafeInteger) + : [] + } + : null + } +} + +/** A missing RPC is a compatibility verdict; a socket failure is not. */ +export function classifyHostedRoomCapability( + probe: unknown, + { connectionId = null }: { connectionId?: null | string } = {} +): HostedRoomCapability { + const candidate = record(probe) + const error = probe instanceof Error ? probe : candidate?.ok === false ? candidate.error || probe : candidate?.error + const localConnectionId = text(connectionId) + + if (error) { + const unsupported = isMissingCapabilityMethod(error) + + return { + kind: unsupported ? 'unsupported' : 'transient-failure', + reason: unsupported ? 'old-gateway' : 'probe-failed', + connectionId: localConnectionId, + exactPeerGrantRevoke: false, + authorityId: null, + persistentProcess: null, + routeGrantFingerprint: false, + reciprocalControl: false, + roomLink: null, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const capabilities = capabilityResult(probe) + + if (!capabilities) { + return { + kind: 'transient-failure', + reason: 'invalid-response', + connectionId: localConnectionId, + exactPeerGrantRevoke: false, + authorityId: null, + persistentProcess: null, + routeGrantFingerprint: false, + reciprocalControl: false, + roomLink: null, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const features = Array.isArray(capabilities.features) ? capabilities.features.map(String) : [] + const reciprocalControl = features.includes('reciprocal_room_control') + + if (capabilities.driver !== true) { + return { + kind: 'unsupported', + reason: capabilities.driver === false ? 'driver-disabled' : 'incomplete-contract', + connectionId: localConnectionId, + exactPeerGrantRevoke: false, + authorityId: null, + persistentProcess: capabilities.persistent_process === true, + routeGrantFingerprint: false, + reciprocalControl, + roomLink: roomLinkCapability(capabilities.room_link), + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const authorityId = text(capabilities.authority_gateway_id) + + if (!authorityId) { + return { + kind: 'unsupported', + reason: 'incomplete-contract', + connectionId: localConnectionId, + exactPeerGrantRevoke: false, + authorityId: null, + persistentProcess: capabilities.persistent_process === true, + routeGrantFingerprint: false, + reciprocalControl, + roomLink: roomLinkCapability(capabilities.room_link), + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + return { + kind: 'driver-capable', + reason: null, + connectionId: localConnectionId, + exactPeerGrantRevoke: + Array.isArray(capabilities.methods) && capabilities.methods.includes('groups.peer.revoke_exact'), + authorityId, + persistentProcess: capabilities.persistent_process === true, + routeGrantFingerprint: + Array.isArray(capabilities.features) && capabilities.features.includes('peer_route_grant_fingerprint'), + reciprocalControl, + roomLink: roomLinkCapability(capabilities.room_link), + maxLogLimit: positiveInteger(capabilities.max_log_limit, 100) || 100, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } +} + +export function isHostedRoomContinuityEligible(capability: unknown): boolean { + const candidate = record(capability) + + if (!candidate) { + return false + } + + if (Object.prototype.hasOwnProperty.call(candidate, 'kind')) { + return candidate.kind === 'driver-capable' && candidate.persistentProcess === true + } + + return candidate.driver === true && candidate.persistent_process === true +} + +function memberConnectionId(member: GroupMember, activeConnectionId: null | string): null | string { + if (!member || member.sourceMissing) { + return null + } + + const explicit = text(member.route?.connectionId) || text(member.connectionId) + + if (explicit) { + return explicit + } + + if (member.sourceScoped || member.remoteSource) { + return null + } + + return text(activeConnectionId) +} + +export function resolveSingleGatewayRoute( + members: GroupMember[], + { activeConnectionId = null }: { activeConnectionId?: null | string } = {} +): HostedRoomRouteResolution { + const roster = Array.isArray(members) ? members : [] + + if (roster.length < MIN_ROOM_MEMBERS || roster.length > MAX_ROOM_MEMBERS) { + return { + kind: 'unsupported', + reason: 'member-count', + connectionId: null, + memberConnectionIds: [], + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const memberConnectionIds = roster.map(member => memberConnectionId(member, activeConnectionId)) + + if (memberConnectionIds.some(connectionId => !connectionId)) { + return { + kind: 'unsupported', + reason: 'unresolved-member-route', + connectionId: null, + memberConnectionIds, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const distinct = new Set(memberConnectionIds) + + if (distinct.size !== 1) { + return { + kind: 'unsupported', + reason: 'cross-gateway', + connectionId: null, + memberConnectionIds, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + return { + kind: 'single-gateway', + reason: null, + connectionId: memberConnectionIds[0], + memberConnectionIds, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } +} + +/** Choose the simplest autonomous plan without widening any gateway's + * advertised capability. */ +export function resolveAutonomousRoomPlan( + members: GroupMember[], + { + activeConnectionId = null, + capabilities = {} + }: { + activeConnectionId?: null | string + capabilities?: Record + } = {} +): AutonomousRoomPlan { + const roster = Array.isArray(members) ? members : [] + + const route = resolveSingleGatewayRoute(roster, { + activeConnectionId + }) + + if (route.reason && route.reason !== 'cross-gateway') { + return { + ...route, + homeConnectionId: null, + remoteConnectionIds: [] + } + } + + const memberConnectionIds = roster.map(member => memberConnectionId(member, activeConnectionId)) + const connectionIds = [...new Set(memberConnectionIds.filter((value): value is string => Boolean(value)))] + + const homeCandidates = connectionIds.filter(connectionId => { + const capability = capabilities[connectionId] + + if (capability?.kind !== 'driver-capable' || capability.persistentProcess !== true) { + return false + } + + if (connectionIds.length === 1) { + return true + } + + const roomLink = capability.roomLink + + return Boolean( + roomLink?.enabled === true && + roomLink.catalog?.persistentProcess === true && + roomLink.catalog.protocolVersions.includes(ROOM_LINK_PROTOCOL_VERSION) && + roomLink.catalog.linkModes.includes('direct') + ) + }) + + const preferredHome = text(activeConnectionId) + + const homeConnectionId = + preferredHome && homeCandidates.includes(preferredHome) ? preferredHome : homeCandidates[0] || null + + if (!homeConnectionId) { + return { + kind: 'unsupported', + reason: 'no-persistent-home', + connectionId: null, + homeConnectionId: null, + memberConnectionIds, + remoteConnectionIds: connectionIds, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const remoteConnectionIds = connectionIds.filter(connectionId => connectionId !== homeConnectionId) + + const unsupportedRemote = remoteConnectionIds.find(connectionId => { + const roomLink = capabilities[connectionId]?.roomLink + + return !( + roomLink?.enabled === true && + roomLink.catalog?.persistentProcess === true && + roomLink.catalog.text === true && + roomLink.catalog.installationId && + roomLink.catalog.digest && + roomLink.catalog.protocolVersions.includes(ROOM_LINK_PROTOCOL_VERSION) && + roomLink.catalog.linkModes.includes('direct') + ) + }) + + if (unsupportedRemote) { + return { + kind: 'unsupported', + reason: 'remote-needs-setup', + connectionId: null, + homeConnectionId, + memberConnectionIds, + remoteConnectionIds, + unavailableConnectionId: unsupportedRemote, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + const unreachableRemote = remoteConnectionIds.find(connectionId => !capabilities[connectionId]?.roomLink?.endpoint) + + if (unreachableRemote) { + return { + kind: 'unsupported', + reason: 'remote-needs-address', + connectionId: null, + homeConnectionId, + memberConnectionIds, + remoteConnectionIds, + unavailableConnectionId: unreachableRemote, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } + } + + return { + kind: remoteConnectionIds.length ? 'multi-gateway' : 'single-gateway', + reason: null, + connectionId: homeConnectionId, + homeConnectionId, + memberConnectionIds, + remoteConnectionIds, + limits: HOSTED_ROOM_CLIENT_LIMITATIONS + } +} + +export function describeAutonomousRoomPlan( + plan: AutonomousRoomPlan, + { homeLabel = 'one host', unavailableLabel = 'One host' } = {} +) { + if (plan.kind === 'multi-gateway') { + return { + defaultEnabled: true, + level: 'distributed' as const, + title: 'Works without Desktop', + description: 'Bots can continue while Desktop is closed.' + } + } + + if (plan.kind === 'single-gateway') { + return { + defaultEnabled: true, + level: 'gateway' as const, + title: 'Works without Desktop', + description: 'Bots can continue while Desktop is closed.' + } + } + + const needsSetup = ['remote-needs-address', 'remote-needs-setup'].includes(String(plan.reason || '')) + + return { + defaultEnabled: false, + level: 'desktop' as const, + title: 'Keep Desktop open', + description: needsSetup + ? `${unavailableLabel} can't keep this Group Chat running yet.` + : 'Bots pause when Desktop closes.' + } +} + +export function profileScopedRoomLinkEndpoint(endpoint: unknown, profile: unknown) { + const base = text(endpoint)?.replace(/\/+$/, '') || null + const targetProfile = text(profile) + + if (!base || !targetProfile || targetProfile === 'default') { + return base + } + + const suffix = `/p/${encodeURIComponent(targetProfile)}` + + if (base.endsWith(suffix)) { + return base + } + + if (/\/p\/[^/]+$/i.test(base)) { + return null + } + + return `${base}${suffix}` +} + +export function describeHostedRoomCreationError(error: unknown) { + const message = errorMessage(error) + + if (/unreachable|name or service not known|timed? ?out|connection refused|network is unreachable/i.test(message)) { + return 'One Bot host cannot reach another. Check that both are online, then try again.' + } + + if (/non-json|authorization|grant|renewal|http 40[13]|unknown or unconfigured profile/i.test(message)) { + return 'One Bot host could not verify this Group Chat. Update or reconnect it, then try again.' + } + + if (/capability catalog changed|changed during setup/i.test(message)) { + return 'A Bot host changed while the Group Chat was being created. Wait for it to reconnect, then try again.' + } + + return null +} + +function cloneRecords(value: unknown): Array> { + return Array.isArray(value) ? value.map(item => ({ ...(record(item) || {}) })) : [] +} + +export function createHostedRoomReplayState( + input: Partial & { roomId?: null | string } = {} +): HostedRoomReplayState { + const cursor = nonNegativeInteger(input.cursor) + + return { + roomId: text(input.roomId), + name: typeof input.name === 'string' ? input.name : '', + members: cloneRecords(input.members), + authorityId: text(input.authorityId), + authorityEpoch: positiveInteger(input.authorityEpoch), + connectionId: text(input.connectionId), + cursor, + latestSeq: Math.max(cursor, nonNegativeInteger(input.latestSeq, cursor)), + messages: Array.isArray(input.messages) ? input.messages.map(message => ({ ...message })) : [], + activity: Array.isArray(input.activity) ? input.activity.map(entry => ({ ...entry })) : [], + timeline: Array.isArray(input.timeline) ? input.timeline.map(entry => ({ ...entry })) : [], + pendingEvents: Array.isArray(input.pendingEvents) ? input.pendingEvents.map(entry => ({ ...entry })) : [], + lastStatusEvent: input.lastStatusEvent ? { ...input.lastStatusEvent } : null, + deleted: Boolean(input.deleted), + conflicts: Array.isArray(input.conflicts) ? input.conflicts.map(conflict => ({ ...conflict })) : [] + } +} + +function normalizeEvent(raw: unknown): HostedRoomEvent | null { + const candidate = record(raw) + + if (!candidate) { + return null + } + + const seq = positiveInteger(candidate.seq) + const eventId = text(candidate.event_id) || text(candidate.eventId) + const kind = text(candidate.kind) + + if (!seq || !eventId || !kind) { + return null + } + + return { + roomId: text(candidate.room_id) || text(candidate.roomId), + seq, + eventId, + kind, + actor: { ...(record(candidate.actor) || {}) }, + payload: { ...(record(candidate.payload) || {}) }, + createdAt: timestampMilliseconds(candidate.created_at ?? candidate.createdAt) + } +} + +function memberLabel(event: HostedRoomEvent): string { + return ( + text(event.payload.member_display_name) || + text(event.payload.member_name) || + text(event.payload.display_name) || + text(event.actor.display_name) || + text(event.actor.profile) || + '' + ) +} + +function messageFromEvent(event: HostedRoomEvent): HostedReplayMessage { + const user = event.kind === 'message.user' + + return { + seq: event.seq, + eventId: event.eventId, + from: { + kind: user ? 'user' : 'member', + name: user ? 'You' : memberLabel(event), + ...(text(event.actor.connection_id) ? { source: text(event.actor.connection_id) || undefined } : {}) + }, + text: typeof event.payload.text === 'string' ? event.payload.text : '', + thread: text(event.payload.thread_id) || text(event.payload.thread) || 'legacy', + at: event.createdAt + } +} + +function applyReplayEvent(state: HostedRoomReplayState, event: HostedRoomEvent): void { + if (KNOWN_EVENT_KINDS.has(event.kind)) { + state.timeline.push({ + seq: event.seq, + eventId: event.eventId, + kind: event.kind + }) + } + + if (event.kind === 'message.user' || event.kind === 'message.member') { + state.messages.push(messageFromEvent(event)) + } else if (event.kind === 'room.created') { + state.name = text(event.payload.name) || state.name + + if (Array.isArray(event.payload.members)) { + state.members = cloneRecords(event.payload.members) + } + } else if (event.kind === 'room.renamed') { + state.name = text(event.payload.name) || state.name + } else if (event.kind === 'room.members_changed' && Array.isArray(event.payload.members)) { + state.members = cloneRecords(event.payload.members) + } else if (event.kind === 'room.disbanded') { + state.deleted = true + } else if (event.kind === 'authority.claimed') { + const authorityId = text(event.payload.authority_gateway_id) + + if (authorityId) { + if (state.authorityId && state.authorityId !== authorityId) { + state.connectionId = null + } + + state.authorityId = authorityId + } + + state.authorityEpoch = positiveInteger(event.payload.authority_epoch, state.authorityEpoch) + } else if (event.kind === 'authority.lost') { + state.connectionId = null + } + + if (STATUS_EVENT_KINDS.has(event.kind)) { + state.activity.push({ + seq: event.seq, + eventId: event.eventId, + kind: event.kind, + member: memberLabel(event), + reasonCode: text(event.payload.reason_code), + at: event.createdAt + }) + state.lastStatusEvent = event + } +} + +export function reduceHostedRoomEvents(state: HostedRoomReplayState, incomingEvents: unknown[]): HostedRoomReplayState { + const next = createHostedRoomReplayState(state) + const bySeq = new Map() + const byId = new Map() + + for (const candidate of [...next.pendingEvents, ...(Array.isArray(incomingEvents) ? incomingEvents : [])]) { + const event = normalizeEvent(candidate) + + if (!event || event.seq <= next.cursor || (next.roomId && event.roomId && next.roomId !== event.roomId)) { + continue + } + + const prior = bySeq.get(event.seq) || byId.get(event.eventId) + + if (prior) { + if (prior.seq !== event.seq || prior.eventId !== event.eventId) { + next.conflicts.push({ + seq: event.seq, + eventId: event.eventId + }) + } + + continue + } + + bySeq.set(event.seq, event) + byId.set(event.eventId, event) + } + + const pending = [...bySeq.values()].sort( + (left, right) => left.seq - right.seq || left.eventId.localeCompare(right.eventId) + ) + + next.latestSeq = Math.max(next.latestSeq, ...pending.map(event => event.seq), next.cursor) + + while (pending.length && pending[0].seq === next.cursor + 1) { + const event = pending.shift() + + if (!event) { + break + } + + applyReplayEvent(next, event) + next.cursor = event.seq + } + + next.pendingEvents = pending + + return next +} + +function status( + kind: string, + options: Pick = {} +): FriendlyHostedRoomStatus { + return { + kind, + ...options + } +} + +export function deriveFriendlyHostedRoomStatus(state: HostedRoomReplayState): FriendlyHostedRoomStatus { + if (state.deleted) { + return status('deleted') + } + + if (state.authorityId && !state.connectionId) { + return status('offline', { + canRetry: true + }) + } + + const event = state.lastStatusEvent + + if (!event) { + return status('ready') + } + + const member = memberLabel(event) + + if (event.kind === 'turn.started' || event.kind === 'turn.reassigned') { + return status('working', { + member, + canStop: true + }) + } + + if (event.kind === 'member.unavailable') { + return status('member-unavailable', { + member, + canRetry: true + }) + } + + if (event.kind === 'turn.failed') { + const reason = text(event.payload.reason_code) + + const needsAttention = [ + 'provider_auth_or_access', + 'provider_quota_limit', + 'missing_config', + 'agent_blocked' + ].includes(reason || '') + + return status(needsAttention ? 'needs-attention' : 'failed', { + member, + reasonCode: reason, + canRetry: !needsAttention + }) + } + + if (event.kind === 'turn.deferred') { + return status('waiting', { + member, + canRetry: true + }) + } + + if (event.kind === 'turn.cancelled') { + return status('stopped') + } + + if (event.kind === 'turn.settled') { + return status('ready') + } + + if (event.kind === 'room.activity') { + const activity = text(event.payload.status)?.toLowerCase() + + if (activity === 'working') { + return status('working', { + member, + canStop: true + }) + } + + if (activity === 'needs_user' || activity === 'waiting_for_user') { + return status('needs-you') + } + } + + return status('ready') +} + +export async function replayHostedRoomPages({ + state, + fetchPage, + pageSize = 100, + maxPages = 20 +}: { + fetchPage: (request: { limit: number; sinceSeq: number }) => Promise + maxPages?: number + pageSize?: number + state: HostedRoomReplayState +}) { + const limit = Math.min(MAX_REPLAY_PAGE_SIZE, Math.max(1, positiveInteger(pageSize, 100) || 100)) + const pageBound = Math.min(MAX_REPLAY_PAGES, Math.max(1, positiveInteger(maxPages, 20) || 20)) + let next = createHostedRoomReplayState(state) + let pages = 0 + let fetchedEvents = 0 + + while (pages < pageBound) { + const beforeCursor = next.cursor + let rawPage: unknown + + try { + rawPage = await fetchPage({ + sinceSeq: beforeCursor, + limit + }) + } catch (error) { + return { + state: next, + complete: false, + reason: 'transient-failure', + pages, + fetchedEvents, + error + } + } + + const page = record(rawPage) + + if (!page) { + return { + state: next, + complete: false, + reason: 'invalid-response', + pages, + fetchedEvents + } + } + + const events = Array.isArray(page.events) ? page.events : [] + const latestSeq = nonNegativeInteger(page.latest_seq ?? page.latestSeq, next.latestSeq) + + if (events.length > limit) { + return { + state: next, + complete: false, + reason: 'oversized-page', + pages, + fetchedEvents + } + } + + pages += 1 + fetchedEvents += events.length + next = reduceHostedRoomEvents(next, events) + next.latestSeq = Math.max(next.latestSeq, latestSeq) + + const hasMore = page.has_more === true || next.cursor < latestSeq + + if (!hasMore) { + return { + state: next, + complete: next.pendingEvents.length === 0, + reason: next.pendingEvents.length ? 'gap' : null, + pages, + fetchedEvents + } + } + + if (next.cursor <= beforeCursor) { + return { + state: next, + complete: false, + reason: 'stalled', + pages, + fetchedEvents + } + } + } + + return { + state: next, + complete: false, + reason: 'limit', + pages, + fetchedEvents + } +} + +function jsonRecord(value: unknown, label: string): Record { + let cloned: unknown + + try { + cloned = JSON.parse(JSON.stringify(value ?? {})) as unknown + } catch (error) { + throw new TypeError(`${label} must be JSON-serializable`, { + cause: error + }) + } + + const result = record(cloned) || {} + + assertNoRawTransportFields(result) + + return result +} + +function fieldTokens(field: string) { + return String(field) + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) +} + +function assertNoRawTransportFields(value: unknown, location = 'payload'): void { + if (!value || typeof value !== 'object') { + return + } + + if (Array.isArray(value)) { + value.forEach((entry, index) => assertNoRawTransportFields(entry, `${location}[${index}]`)) + + return + } + + for (const [field, nested] of Object.entries(value)) { + const forbidden = fieldTokens(field).find(token => FORBIDDEN_TRANSPORT_FIELD_TOKENS.has(token)) + + if (forbidden) { + throw new TypeError(`${location}.${field} cannot carry raw attachment ${forbidden}`) + } + + assertNoRawTransportFields(nested, `${location}.${field}`) + } +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(',')}]` + } + + if (value && typeof value === 'object') { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => `${JSON.stringify(key)}:${stableJson(nested)}`) + .join(',')}}` + } + + return JSON.stringify(value) +} + +function normalizeCommand(raw: Partial): HostedRoomCommand { + const commandId = text(raw.commandId) + const kind = text(raw.kind) + const roomId = text(raw.roomId) + const connectionId = text(raw.connectionId) + + if ( + !commandId || + !kind || + !['create', 'disband', 'rename', 'retry', 'send', 'stop'].includes(kind) || + !roomId || + !connectionId + ) { + throw new TypeError('hosted room command is incomplete') + } + + return { + commandId, + kind: kind as HostedRoomCommandKind, + roomId, + authorityId: text(raw.authorityId), + connectionId, + payload: jsonRecord(raw.payload, 'command payload'), + status: ['failed', 'in-flight', 'pending'].includes(String(raw.status)) + ? (raw.status as HostedRoomCommandStatus) + : 'pending', + attempts: nonNegativeInteger(raw.attempts), + failureCode: text(raw.failureCode) + } +} + +function commandSignature(command: HostedRoomCommand): string { + return stableJson({ + commandId: command.commandId, + kind: command.kind, + roomId: command.roomId, + authorityId: command.authorityId, + connectionId: command.connectionId, + payload: command.payload + }) +} + +export function createHostedRoomOutbox(persisted: unknown = null): HostedRoomOutbox { + const candidate = record(persisted) + const commands: HostedRoomCommand[] = [] + + for (const raw of Array.isArray(candidate?.commands) ? candidate.commands : []) { + const command = normalizeCommand((record(raw) || {}) as Partial) + const existing = commands.find(entry => entry.commandId === command.commandId) + + command.status = command.status === 'in-flight' ? 'pending' : command.status + + if (!existing) { + commands.push(command) + } else if (commandSignature(existing) !== commandSignature(command)) { + throw new TypeError(`commandId ${command.commandId} has conflicting persisted content`) + } + } + + return { + version: 1, + commands + } +} + +export function reduceHostedRoomOutbox(state: HostedRoomOutbox, action: HostedRoomOutboxAction): HostedRoomOutbox { + const current = state && Array.isArray(state.commands) ? state : createHostedRoomOutbox() + + if (action.type === 'enqueue') { + const command = normalizeCommand(action.command) + const existing = current.commands.find(entry => entry.commandId === command.commandId) + + command.status = 'pending' + + if (existing) { + if (commandSignature(existing) !== commandSignature(command)) { + throw new TypeError(`commandId ${command.commandId} is already bound to different content`) + } + + return current + } + + if (current.commands.length >= MAX_HOSTED_ROOM_OUTBOX_COMMANDS) { + throw new TypeError( + 'Too many Group Chat changes are waiting to sync. Reconnect the affected device and try again.' + ) + } + + return { + ...current, + commands: [...current.commands, command] + } + } + + const commandId = text(action.commandId) + + if (!commandId) { + throw new TypeError('outbox action requires commandId') + } + + if (action.type === 'acknowledge') { + return { + ...current, + commands: current.commands.filter(command => command.commandId !== commandId) + } + } + + return { + ...current, + commands: current.commands.map(command => { + if (command.commandId !== commandId) { + return command + } + + if (action.type === 'dispatch') { + return { + ...command, + status: 'in-flight' as const, + attempts: command.attempts + 1, + failureCode: null + } + } + + if (action.type === 'terminal-failure') { + return { + ...command, + status: 'failed' as const, + failureCode: text(action.failureCode) || 'command-failed' + } + } + + return { + ...command, + status: 'pending' as const + } + }) + } +} diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.test.ts new file mode 100644 index 0000000000000..999b1b1c64d5f --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.test.ts @@ -0,0 +1,631 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { pluginSdkMock } from './group-test-utils' + +const EXPECTED_GRANT_SHA256 = 'c'.repeat(64) + +const mocks = vi.hoisted(() => { + const capabilities = { + value: {} as Record, + get() { + return this.value + }, + set(value: Record) { + this.value = value + } + } + + return { + addCleanup: vi.fn(async () => undefined), + armCleanup: vi.fn(async () => undefined), + capabilities, + dispatchCleanup: vi.fn(async () => undefined), + host: {} as Record, + lifecycle: { value: 1 }, + refresh: vi.fn(async () => undefined), + invalidate: vi.fn(), + requestForBot: vi.fn(), + requestHosted: vi.fn(), + releaseCleanup: vi.fn(async () => undefined) + } +}) + +vi.mock('@hermes/plugin-sdk', async () => pluginSdkMock(mocks.host)) + +vi.mock('./hosted-room-runtime', () => ({ + $hostedRoomCapabilities: mocks.capabilities, + invalidateHostedRoomPoll: mocks.invalidate, + hostedRoomLifecycleIsCurrent: (token: number) => token === mocks.lifecycle.value, + hostedRoomLifecycleToken: () => mocks.lifecycle.value, + refreshHostedRooms: mocks.refresh, + requestHostedConnection: mocks.requestHosted +})) + +vi.mock('./hosted-room-cleanup', () => ({ + addHostedRoomCleanup: mocks.addCleanup, + armHostedRoomCleanup: mocks.armCleanup, + dispatchHostedRoomCleanup: mocks.dispatchCleanup, + releaseHostedRoomCleanup: mocks.releaseCleanup +})) + +vi.mock('./routing', () => ({ + requestForBot: mocks.requestForBot +})) + +beforeEach(() => { + vi.clearAllMocks() + mocks.lifecycle.value = 1 + mocks.capabilities.value = { + home: { + authorityId: 'install:home', + routeGrantFingerprint: true + } + } + Object.assign(mocks.host, { + profileRoutes: async () => [ + { connectionId: 'home', mode: 'remote', profile: 'default', targetProfile: 'default' }, + { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' } + ] + }) +}) + +describe('hosted Group Chat peer reauthorization', () => { + it('issues a fresh peer grant and registers it on the existing authority', async () => { + const { $groupChats } = await import('./group-chat') + const { reconnectHostedGroupChatPeer } = await import('./hosted-room-reauthorization') + + $groupChats.set({ + Release: { + continuityMode: 'distributed', + hosted: 'install:home', + hostedConnectionId: 'home', + hostedEpoch: 1, + log: [], + members: [ + { + connectionId: 'peer', + handle: 'builder', + name: 'builder', + route: { + connectionId: 'peer', + mode: 'remote', + profile: 'builder', + targetProfile: 'builder' + }, + sourceScoped: true, + targetProfile: 'builder' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + + mocks.requestHosted.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [ + { + grant_sha256: EXPECTED_GRANT_SHA256, + member_id: 'member-builder', + status: 'needs_reauthorization' + } + ] + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: [ + { + display_name: 'Builder', + handle: 'builder', + member_id: 'member-builder', + profile: 'builder', + target: { + installation_id: 'install:peer', + kind: 'peer', + peer_id: 'install:peer' + } + } + ], + room_id: 'room-1' + } + } + } + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:peer', + methods: ['groups.peer.revoke_exact'], + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://peer.example.test:19445' }, + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected hosted method: ${method}`) + }) + mocks.requestForBot.mockResolvedValue({ + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + }, + grant: 'private-grant', + target_profile: 'builder' + }) + + await reconnectHostedGroupChatPeer('Release', 'member-builder') + + expect(mocks.requestForBot).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'peer', targetProfile: 'builder' }), + 'groups.peer.invite', + expect.objectContaining({ + authority_epoch: 1, + authority_gateway_id: 'install:home', + member_id: 'member-builder', + room_id: 'room-1' + }) + ) + expect(mocks.requestHosted).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'home' }), + 'groups.peer.register', + expect.objectContaining({ + grant: 'private-grant', + member_id: 'member-builder', + room_id: 'room-1', + target_profile: 'builder', + target_url: 'https://peer.example.test:19445/p/builder' + }) + ) + expect(mocks.refresh).toHaveBeenCalledOnce() + expect(mocks.invalidate).toHaveBeenCalledWith('room-1') + expect(mocks.addCleanup).toHaveBeenCalledTimes(2) + expect(mocks.releaseCleanup).toHaveBeenCalledOnce() + }) + + it('revokes the fresh peer grant when home registration fails', async () => { + const { $groupChats } = await import('./group-chat') + const { reconnectHostedGroupChatPeer } = await import('./hosted-room-reauthorization') + + $groupChats.set({ + Release: { + continuityMode: 'distributed', + hosted: 'install:home', + hostedConnectionId: 'home', + hostedEpoch: 1, + log: [], + members: [ + { + connectionId: 'peer', + handle: 'builder', + name: 'builder', + route: { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' }, + sourceScoped: true, + targetProfile: 'builder' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + let stateCalls = 0 + mocks.requestHosted.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + stateCalls += 1 + return { + driver_status: { + peer_routes: [ + { + grant_sha256: EXPECTED_GRANT_SHA256, + member_id: 'member-builder', + status: 'needs_reauthorization' + } + ] + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: [ + { + handle: 'builder', + member_id: 'member-builder', + profile: 'builder', + target: { installation_id: 'install:peer', kind: 'peer' } + } + ] + } + } + } + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:peer', + methods: ['groups.peer.revoke_exact'], + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://peer.example.test:19445' }, + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.peer.register') { + throw new Error('register failed') + } + + throw new Error(`unexpected hosted method: ${method}`) + }) + mocks.requestForBot.mockResolvedValue({ + catalog: { + catalog_digest: 'digest:peer', + installation_id: 'install:peer' + }, + grant: 'private-grant', + target_profile: 'builder' + }) + await expect(reconnectHostedGroupChatPeer('Release', 'member-builder')).rejects.toThrow('register failed') + expect(mocks.armCleanup).toHaveBeenCalledOnce() + expect(mocks.dispatchCleanup).toHaveBeenCalledOnce() + expect(mocks.refresh).not.toHaveBeenCalled() + expect(mocks.invalidate).not.toHaveBeenCalled() + }) + + it('journals the fresh grant before rejecting an invalid invitation', async () => { + const { $groupChats } = await import('./group-chat') + const { reconnectHostedGroupChatPeer } = await import('./hosted-room-reauthorization') + + $groupChats.set({ + Release: { + continuityMode: 'distributed', + hosted: 'install:home', + hostedConnectionId: 'home', + hostedEpoch: 1, + log: [], + members: [ + { + connectionId: 'peer', + handle: 'builder', + name: 'builder', + route: { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' }, + sourceScoped: true, + targetProfile: 'builder' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + mocks.requestHosted.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [ + { + grant_sha256: EXPECTED_GRANT_SHA256, + member_id: 'member-builder', + status: 'needs_reauthorization' + } + ] + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: [ + { + handle: 'builder', + member_id: 'member-builder', + profile: 'builder', + target: { installation_id: 'install:peer', kind: 'peer' } + } + ] + } + } + } + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:peer', + methods: ['groups.peer.revoke_exact'], + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://peer.example.test:19445' }, + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + throw new Error(`unexpected hosted method: ${method}`) + }) + mocks.requestForBot.mockResolvedValue({ + catalog: { + installation_id: 'install:peer' + }, + grant: 'private-grant', + target_profile: 'builder' + }) + + await expect(reconnectHostedGroupChatPeer('Release', 'member-builder')).rejects.toThrow( + 'could not prepare a secure connection' + ) + expect(mocks.addCleanup).toHaveBeenCalledOnce() + expect(mocks.addCleanup).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: 'peer', + grant: 'private-grant', + kind: 'peer-revoke-exact', + profile: 'builder' + }) + ) + expect(mocks.armCleanup).toHaveBeenCalledOnce() + expect(mocks.dispatchCleanup).toHaveBeenCalledOnce() + expect(mocks.requestHosted).not.toHaveBeenCalledWith(expect.anything(), 'groups.peer.register', expect.anything()) + }) + + it('does not report success when a registration reply is lost', async () => { + const { $groupChats } = await import('./group-chat') + const { reconnectHostedGroupChatPeer } = await import('./hosted-room-reauthorization') + + $groupChats.set({ + Release: { + continuityMode: 'distributed', + hosted: 'install:home', + hostedConnectionId: 'home', + hostedEpoch: 1, + log: [], + members: [ + { + connectionId: 'peer', + handle: 'builder', + name: 'builder', + route: { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' }, + sourceScoped: true, + targetProfile: 'builder' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + let stateCalls = 0 + mocks.requestHosted.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + stateCalls += 1 + return { + driver_status: + stateCalls > 1 + ? { + peer_routes: [ + { + member_id: 'member-builder', + status: 'ready', + grant_sha256: '73238410238d13fffbccfb5ba0142555042d7153fd8196fcf6bba1c1ead06c5a' + } + ] + } + : { + peer_routes: [ + { + grant_sha256: EXPECTED_GRANT_SHA256, + member_id: 'member-builder', + status: 'needs_reauthorization' + } + ] + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: [ + { + handle: 'builder', + member_id: 'member-builder', + profile: 'builder', + target: { installation_id: 'install:peer', kind: 'peer' } + } + ] + } + } + } + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:peer', + methods: ['groups.peer.revoke_exact'], + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://peer.example.test:19445' }, + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.peer.register') { + throw new Error('response lost') + } + + throw new Error(`unexpected hosted method: ${method}`) + }) + mocks.requestForBot.mockResolvedValue({ + catalog: { + catalog_digest: 'digest:peer', + installation_id: 'install:peer' + }, + grant: 'private-grant', + target_profile: 'builder' + }) + + await expect(reconnectHostedGroupChatPeer('Release', 'member-builder')).rejects.toThrow('response lost') + expect(mocks.armCleanup).toHaveBeenCalledOnce() + expect(mocks.dispatchCleanup).toHaveBeenCalledOnce() + expect(mocks.invalidate).not.toHaveBeenCalled() + expect(mocks.refresh).not.toHaveBeenCalled() + }) + + it('coalesces clicks within one lifecycle but lets a restarted runtime retry independently', async () => { + const { $groupChats } = await import('./group-chat') + const { reconnectHostedGroupChatPeer } = await import('./hosted-room-reauthorization') + + $groupChats.set({ + Release: { + continuityMode: 'distributed', + hosted: 'install:home', + hostedConnectionId: 'home', + hostedEpoch: 1, + log: [], + members: [ + { + connectionId: 'peer', + handle: 'builder', + name: 'builder', + route: { connectionId: 'peer', mode: 'remote', profile: 'builder', targetProfile: 'builder' }, + sourceScoped: true, + targetProfile: 'builder' + } + ], + roomId: 'room-1', + watermarks: {} + } + }) + mocks.requestHosted.mockImplementation(async (_route, method) => { + if (method === 'groups.state') { + return { + driver_status: { + peer_routes: [ + { + grant_sha256: EXPECTED_GRANT_SHA256, + member_id: 'member-builder', + status: 'needs_reauthorization' + } + ] + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: [ + { + handle: 'builder', + member_id: 'member-builder', + profile: 'builder', + target: { installation_id: 'install:peer', kind: 'peer' } + } + ] + } + } + } + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:peer', + methods: ['groups.peer.revoke_exact'], + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: 'https://peer.example.test:19445' }, + catalog: { + attachments: true, + catalog_digest: 'digest:peer', + installation_id: 'install:peer', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected hosted method: ${method}`) + }) + const releaseInvites: Array<(grant: string) => void> = [] + mocks.requestForBot.mockImplementation( + () => + new Promise(resolve => { + releaseInvites.push(grant => + resolve({ + catalog: { + catalog_digest: 'digest:peer', + installation_id: 'install:peer' + }, + grant, + target_profile: 'builder' + }) + ) + }) + ) + + const first = reconnectHostedGroupChatPeer('Release', 'member-builder') + const second = reconnectHostedGroupChatPeer('Release', 'member-builder') + + expect(first).toBe(second) + await vi.waitFor(() => expect(mocks.requestForBot).toHaveBeenCalledOnce()) + mocks.lifecycle.value = 2 + const third = reconnectHostedGroupChatPeer('Release', 'member-builder') + + expect(third).not.toBe(first) + await vi.waitFor(() => expect(mocks.requestForBot).toHaveBeenCalledTimes(2)) + releaseInvites[0]('old-private-grant') + releaseInvites[1]('new-private-grant') + + await expect(first).rejects.toThrow('connections changed') + await expect(second).rejects.toThrow('connections changed') + await expect(third).resolves.toBeUndefined() + expect(mocks.armCleanup).toHaveBeenCalled() + expect(mocks.dispatchCleanup).toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.ts new file mode 100644 index 0000000000000..3aebe4409d803 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-reauthorization.ts @@ -0,0 +1,305 @@ +/** Explicitly renew one peer route after its policy or capability catalog changed. */ + +import { host } from '@hermes/plugin-sdk' + +import { $groupChats, groupChatHostedGateway } from './group-chat' +import { + addHostedRoomCleanup, + armHostedRoomCleanup, + dispatchHostedRoomCleanup, + releaseHostedRoomCleanup +} from './hosted-room-cleanup' +import { + classifyHostedRoomCapability, + isHostedRoomContinuityEligible, + profileScopedRoomLinkEndpoint +} from './hosted-room-client' +import { + $hostedRoomCapabilities, + hostedRoomLifecycleIsCurrent, + hostedRoomLifecycleToken, + invalidateHostedRoomPoll, + refreshHostedRooms, + requestHostedConnection +} from './hosted-room-runtime' +import { requestForBot } from './routing' +import type { GroupMember, ProfileRoute } from './types' + +const reconnectingPeers = new Map }>() + +function record(value: unknown): null | Record { + return value !== null && typeof value === 'object' ? (value as Record) : null +} + +async function sha256(value: string) { + const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)) + + return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('') +} + +async function routes() { + if (typeof host.profileRoutes !== 'function') { + return [] as ProfileRoute[] + } + + const value = await host.profileRoutes() + + return (Array.isArray(value) ? value : []) as ProfileRoute[] +} + +function matchingLocalMember(members: GroupMember[], serverMember: Record) { + const profile = String(serverMember.profile || serverMember.member_id || '') + const handle = String(serverMember.handle || '') + + return members.find( + member => + String(member.targetProfile || member.name || '') === profile && + (!handle || String(member.handle || member.name || '') === handle) + ) +} + +async function reconnectPeer(group: string, memberId: string, lifecycle: number) { + const assertCurrent = () => { + if (!hostedRoomLifecycleIsCurrent(lifecycle)) { + throw new Error('Group Chat connections changed. Try Reconnect again.') + } + } + const room = $groupChats.get()[group] + const roomId = String(room?.roomId || '') + const homeAuthority = groupChatHostedGateway(room) + const allRoutes = await routes() + assertCurrent() + const homeRoute = allRoutes.find(route => String(route.connectionId || '') === String(room?.hostedConnectionId || '')) + + if (!room || !roomId || !homeAuthority || !homeRoute) { + throw new Error('Open the gateway that owns this Group Chat, then try again.') + } + + const homeCapability = $hostedRoomCapabilities.get()[String(homeRoute.connectionId || '')] + + if (!homeCapability?.routeGrantFingerprint || homeCapability.authorityId !== homeAuthority) { + throw new Error('Update the gateway that owns this Group Chat, then try again.') + } + + const state = record( + await requestHostedConnection>(homeRoute, 'groups.state', { + room_id: roomId + }) + ) + assertCurrent() + const serverRoom = record(state?.room) + const driver = record(state?.driver_status) + const authorityId = String(serverRoom?.authority_gateway_id || '') + const authorityEpoch = Number(serverRoom?.authority_epoch || 0) + const serverMember = (Array.isArray(serverRoom?.members) ? serverRoom.members : []) + .map(record) + .find(member => String(member?.member_id || '') === memberId) + const target = record(serverMember?.target) + const targetAuthority = String(target?.installation_id || target?.peer_id || '') + const localMember = serverMember ? matchingLocalMember(room.members || [], serverMember) : null + const peerConnectionId = String(localMember?.route?.connectionId || localMember?.connectionId || '') + const profile = String(serverMember?.profile || serverMember?.member_id || 'default') + const currentPeerRoute = (Array.isArray(driver?.peer_routes) ? driver.peer_routes : []) + .map(record) + .find(route => String(route?.member_id || '') === memberId) + const expectedGrantSha256 = String(currentPeerRoute?.grant_sha256 || '') + const peerRoute = allRoutes.find( + route => + String(route.connectionId || '') === peerConnectionId && + String(route.targetProfile || route.profile || '') === profile + ) + + if ( + !serverMember || + target?.kind !== 'peer' || + !authorityId || + authorityId !== homeAuthority || + !Number.isSafeInteger(authorityEpoch) || + authorityEpoch < 1 || + !localMember || + !targetAuthority || + !/^[0-9a-f]{64}$/.test(expectedGrantSha256) || + !peerRoute + ) { + throw new Error('Reconnect the Bot gateway in Sessions, then try again.') + } + + const peerCapability = classifyHostedRoomCapability(await requestHostedConnection(peerRoute, 'groups.capabilities'), { + connectionId: peerConnectionId + }) + assertCurrent() + + $hostedRoomCapabilities.set({ + ...$hostedRoomCapabilities.get(), + [peerConnectionId]: peerCapability + }) + + if ( + !isHostedRoomContinuityEligible(peerCapability) || + !peerCapability.exactPeerGrantRevoke || + peerCapability.authorityId !== targetAuthority + ) { + throw new Error('That Bot gateway cannot reconnect to this Group Chat yet.') + } + + const invitation = record( + await requestForBot(localMember, 'groups.peer.invite', { + room_id: roomId, + home_install_id: authorityId, + authority_gateway_id: authorityId, + authority_epoch: authorityEpoch, + member_id: memberId, + profile + }) + ) + const catalog = record(invitation?.catalog) + const grant = String(invitation?.grant || '') + const targetProfile = String(invitation?.target_profile || profile) + const targetUrl = profileScopedRoomLinkEndpoint(peerCapability.roomLink?.endpoint, invitation?.target_profile) + const setupId = `reconnect:${roomId}:${memberId}:${globalThis.crypto?.randomUUID?.() || Date.now()}` + const operationId = `${setupId}:grant` + + const revokeFreshGrant = async () => { + if (!grant) { + return + } + + await requestHostedConnection(peerRoute, 'groups.peer.revoke_exact', { + grant, + profile: targetProfile + }) + } + + if (grant) { + try { + await addHostedRoomCleanup({ + operationId, + setupId, + kind: 'peer-revoke-exact', + connectionId: peerConnectionId, + profile: targetProfile, + grant, + roomId: null, + cancelId: null, + homeConnectionId: null, + homeProfile: null, + memberId: null, + targetUrl: null, + catalog: null + }) + } catch (error) { + await revokeFreshGrant() + throw error + } + } + + const abandonIfStale = async () => { + if (hostedRoomLifecycleIsCurrent(lifecycle)) { + return + } + + await armHostedRoomCleanup(setupId) + await dispatchHostedRoomCleanup() + assertCurrent() + } + + await abandonIfStale() + + let grantSha256 = '' + + if (grant) { + try { + grantSha256 = await sha256(grant) + await abandonIfStale() + } catch (error) { + await armHostedRoomCleanup(setupId) + await dispatchHostedRoomCleanup() + throw error + } + } + + if ( + !grant || + !catalog?.installation_id || + String(catalog.installation_id) !== targetAuthority || + !catalog.catalog_digest || + !targetProfile || + targetProfile !== profile || + !targetUrl + ) { + if (grant) { + await armHostedRoomCleanup(setupId) + await dispatchHostedRoomCleanup() + } + throw new Error('That Bot gateway could not prepare a secure connection.') + } + + try { + await addHostedRoomCleanup({ + operationId, + setupId, + kind: 'peer-reconnect', + connectionId: peerConnectionId, + profile: targetProfile, + grant, + grantSha256, + expectedGrantSha256, + roomId, + cancelId: null, + homeConnectionId: String(homeRoute.connectionId || ''), + homeProfile: String(homeRoute.targetProfile || homeRoute.profile || 'default'), + memberId, + targetUrl, + catalog + }) + } catch (error) { + await armHostedRoomCleanup(setupId) + await dispatchHostedRoomCleanup() + throw error + } + + await abandonIfStale() + + try { + await requestHostedConnection(homeRoute, 'groups.peer.register', { + room_id: roomId, + member_id: memberId, + target_url: targetUrl, + target_profile: targetProfile, + grant, + catalog, + expected_grant_sha256: expectedGrantSha256 + }) + await abandonIfStale() + } catch (error) { + await armHostedRoomCleanup(setupId) + await dispatchHostedRoomCleanup() + throw error + } + + await abandonIfStale() + await releaseHostedRoomCleanup(setupId).catch(() => undefined) + assertCurrent() + invalidateHostedRoomPoll(roomId) + await refreshHostedRooms().catch(() => undefined) +} + +export function reconnectHostedGroupChatPeer(group: string, memberId: string) { + const key = `${group}:${memberId}` + const lifecycle = hostedRoomLifecycleToken() + const existing = reconnectingPeers.get(key) + + if (existing?.lifecycle === lifecycle) { + return existing.task + } + + const task = reconnectPeer(group, memberId, lifecycle).finally(() => { + if (reconnectingPeers.get(key)?.task === task) { + reconnectingPeers.delete(key) + } + }) + + reconnectingPeers.set(key, { lifecycle, task }) + + return task +} diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-reconnect-runtime.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-reconnect-runtime.test.ts new file mode 100644 index 0000000000000..e4b80775bbaaa --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-reconnect-runtime.test.ts @@ -0,0 +1,602 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as groupChat from './group-chat' +import type * as groupRounds from './group-rounds' +import { pluginSdkMock, scriptedStorage } from './group-test-utils' +import type * as hostedRuntime from './hosted-room-runtime' +import type { GroupChat, GroupMember } from './types' + +const { host } = vi.hoisted(() => ({ + host: {} as Record +})) + +vi.mock('@hermes/plugin-sdk', async () => pluginSdkMock(host)) + +interface RpcCall { + connectionId?: string + method: string + params: Record +} + +interface RuntimeRoom { + chat: typeof groupChat + calls: RpcCall[] + rounds: typeof groupRounds + runtime: typeof hostedRuntime + storage: Map +} + +const MEMBERS: GroupMember[] = [ + { + name: 'research', + connectionId: 'gateway-a', + sourceScoped: true, + targetProfile: 'research' + }, + { + name: 'builder', + connectionId: 'gateway-a', + sourceScoped: true, + targetProfile: 'builder' + } +] + +function room(overrides: Partial = {}): GroupChat { + return { + log: [], + watermarks: {}, + members: MEMBERS, + roomId: 'room-1', + hosted: 'install:home', + hostedEpoch: 1, + hostedConnectionId: 'gateway-a', + hostedSeq: 0, + continuityMode: 'gateway', + ...overrides + } +} + +function hostedEvent( + seq: number, + eventId: string, + kind: string, + payload: Record = {}, + actor: Record = { + kind: 'gateway', + id: 'install:home' + } +) { + return { + room_id: 'room-1', + seq, + event_id: eventId, + kind, + actor, + payload, + created_at: seq + } +} + +async function loadRuntime( + handler: ( + method: string, + params: Record, + route?: Record + ) => Promise | unknown, + routes: Array> = [ + { + connectionId: 'gateway-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'default' + } + ] +): Promise { + vi.resetModules() + const calls: RpcCall[] = [] + const storage = new Map() + + for (const key of Object.keys(host)) { + delete host[key] + } + + Object.assign(host, { + activeConnectionId: () => 'gateway-a', + notify: vi.fn(), + profileRoutes: async () => routes, + request: async (method: string, params: Record) => { + calls.push({ + method, + params + }) + + return handler(method, params) + }, + requestProfile: async (route: Record, method: string, params: Record) => { + calls.push({ + connectionId: String(route?.connectionId || ''), + method, + params + }) + + return handler(method, params, route) + }, + state: { + connectionId: { + get: () => 'gateway-a', + listen: () => () => undefined + }, + gateway: { + get: () => 'open', + listen: () => () => undefined + }, + profile: { + get: () => 'default', + listen: () => () => undefined + } + } + }) + + const [chat, rounds, runtime, shared] = await Promise.all([ + import('./group-chat'), + import('./group-rounds'), + import('./hosted-room-runtime'), + import('./shared') + ]) + + shared.setPluginCtx(scriptedStorage(storage)) + + return { + chat, + calls, + rounds, + runtime, + storage + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +describe('hosted Group Chat runtime', () => { + it('does not restart cleanup after stop wins a pending storage load', async () => { + let releaseLoad: (value: unknown) => void = () => undefined + let loadStarted: () => void = () => undefined + const started = new Promise(resolve => { + loadStarted = resolve + }) + const pending = new Promise(resolve => { + releaseLoad = resolve + }) + const loaded = await loadRuntime(method => { + throw new Error(`unexpected method after stop: ${method}`) + }) + const get = vi.fn(async (key: string) => { + if (key === 'hosted-room-outbox-v1') { + loadStarted() + + return pending + } + + return null + }) + const storage = { + get, + set: vi.fn() + } + + const start = loaded.runtime.startHostedRoomRuntime(storage as never) + await started + loaded.runtime.stopHostedRoomRuntime() + releaseLoad(null) + await start + + expect(get).toHaveBeenCalledTimes(1) + expect(get).toHaveBeenCalledWith('hosted-room-outbox-v1', null) + expect(loaded.calls).toEqual([]) + }) + + it('does not let a pre-stop refresh rejection mark a restarted runtime offline', async () => { + let releaseState: () => void = () => undefined + let stateStarted: () => void = () => undefined + const stateRequested = new Promise(resolve => { + stateStarted = resolve + }) + const staleState = new Promise>((_resolve, reject) => { + releaseState = () => reject(new Error('old connection closed')) + }) + let stateCalls = 0 + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { authority_gateway_id: 'install:home', driver: true, persistent_process: true } + } + + if (method === 'groups.list') { + return { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: 0, + members: MEMBERS, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + stateCalls += 1 + if (stateCalls === 1) { + stateStarted() + + return staleState + } + + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + return { events: [], has_more: false, latest_seq: 0 } + } + + throw new Error(`unexpected method: ${method}`) + }) + const storage = scriptedStorage(loaded.storage).storage + + loaded.chat.$groupChats.set({ Release: room() }) + const firstStart = loaded.runtime.startHostedRoomRuntime(storage) + await stateRequested + loaded.runtime.stopHostedRoomRuntime() + const secondStart = loaded.runtime.startHostedRoomRuntime(storage) + releaseState() + await Promise.all([firstStart, secondStart]) + + expect(stateCalls).toBe(2) + expect(loaded.chat.$groupChats.get().Release.hostedStatus?.state).toBe('ready') + loaded.runtime.stopHostedRoomRuntime() + }) + + it('surfaces an explicit reconnect action when a peer route needs reauthorization', async () => { + const serverMembers = [ + { + member_id: 'research', + profile: 'research' + }, + { + display_name: 'Remote Builder', + handle: 'builder', + member_id: 'builder', + profile: 'builder', + target: { + installation_id: 'install:peer', + kind: 'peer', + peer_id: 'install:peer' + } + } + ] + const loaded = await loadRuntime( + (method, _params, route) => { + const connectionId = String(route?.connectionId || '') + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: connectionId === 'gateway-b' ? 'install:peer' : 'install:home', + driver: true, + features: connectionId === 'gateway-a' ? ['peer_route_grant_fingerprint'] : [], + max_log_limit: 100, + methods: connectionId === 'gateway-b' ? ['groups.peer.revoke_exact'] : [], + persistent_process: true + } + } + + if (method === 'groups.list') { + if (connectionId === 'gateway-b') { + return { rooms: [] } + } + + return { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: 0, + members: serverMembers, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + return { + driver_status: { + blocked: true, + peer_routes: [ + { + member_id: 'builder', + status: 'needs_reauthorization' + } + ], + working: false + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: serverMembers, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + return { events: [], has_more: false, latest_seq: 0 } + } + + throw new Error(`unexpected method: ${method}`) + }, + [ + { connectionId: 'gateway-a', mode: 'remote', profile: 'default', targetProfile: 'default' }, + { connectionId: 'gateway-b', mode: 'remote', profile: 'default', targetProfile: 'default' } + ] + ) + + loaded.chat.$groupChats.set({ Release: room() }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + await loaded.runtime.refreshHostedRooms() + + expect(loaded.chat.$groupChats.get().Release).toMatchObject({ + continuityIssue: 'Reconnect Remote Builder to continue this Group Chat.', + hostedStatus: { + canReconnect: true, + canRetry: false, + canStop: false, + label: 'Remote Builder needs your attention.', + reconnectMemberId: 'builder', + state: 'needs-attention' + }, + running: false + }) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('uses the stored member route to explain an older peer gateway without polling forever', async () => { + let peerUpgraded = false + let stateCalls = 0 + const serverMembers = [ + { member_id: 'research', profile: 'research' }, + { + display_name: 'Remote Builder', + handle: 'builder', + member_id: 'builder', + profile: 'builder', + target: { + installation_id: 'install:peer', + kind: 'peer', + peer_id: 'install:peer' + } + } + ] + const loaded = await loadRuntime( + (method, _params, route) => { + const connectionId = String(route?.connectionId || '') + + if (method === 'groups.capabilities') { + if (connectionId === 'gateway-b') { + if (!peerUpgraded) { + throw Object.assign(new Error('Method not found'), { code: -32601 }) + } + + return { + authority_gateway_id: 'install:peer', + driver: true, + methods: ['groups.peer.revoke_exact'], + persistent_process: true + } + } + + return { + authority_gateway_id: 'install:home', + driver: true, + features: ['peer_route_grant_fingerprint'], + persistent_process: true + } + } + + if (method === 'groups.list') { + return connectionId === 'gateway-b' + ? { rooms: [] } + : { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: 0, + members: serverMembers, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + stateCalls += 1 + + return { + driver_status: { + peer_routes: [{ member_id: 'builder', status: 'needs_reauthorization' }], + working: false + }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + members: serverMembers, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + return { events: [], has_more: false, latest_seq: 0 } + } + + throw new Error(`unexpected method: ${method}`) + }, + [ + { connectionId: 'gateway-a', mode: 'remote', profile: 'default', targetProfile: 'default' }, + { connectionId: 'gateway-b', mode: 'remote', profile: 'default', targetProfile: 'default' } + ] + ) + + loaded.chat.$groupChats.set({ + Release: room({ + members: [ + MEMBERS[0], + { + connectionId: 'gateway-b', + handle: 'builder', + name: 'builder', + route: { + connectionId: 'gateway-b', + mode: 'remote', + profile: 'builder', + targetProfile: 'builder' + }, + sourceScoped: true, + targetProfile: 'builder' + } + ] + }) + }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.chat.$groupChats.get().Release.continuityIssue).toMatch(/^Update /) + expect(stateCalls).toBe(1) + + await loaded.runtime.refreshHostedRooms() + expect(stateCalls).toBe(1) + + peerUpgraded = true + vi.setSystemTime(new Date(Date.now() + 31_000)) + await loaded.runtime.refreshHostedRooms() + + expect(stateCalls).toBe(2) + expect(loaded.chat.$groupChats.get().Release.continuityIssue).toBe( + 'Reconnect Remote Builder to continue this Group Chat.' + ) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('does not let an in-flight poll restore a cache entry after invalidation', async () => { + let releaseState: () => void = () => undefined + let stateStarted: () => void = () => undefined + const stateRequested = new Promise(resolve => { + stateStarted = resolve + }) + const heldState = new Promise>(resolve => { + releaseState = () => + resolve({ + driver_status: { working: true }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + }) + }) + let stateCalls = 0 + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { authority_gateway_id: 'install:home', driver: true, persistent_process: true } + } + + if (method === 'groups.list') { + return { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: 0, + members: MEMBERS, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + stateCalls += 1 + if (stateCalls === 1) { + stateStarted() + + return heldState + } + + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + return { events: [], has_more: false, latest_seq: 0 } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ Release: room() }) + const start = loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + await stateRequested + loaded.runtime.invalidateHostedRoomPoll('room-1') + releaseState() + await start + + expect(loaded.chat.$groupChats.get().Release.hostedStatus?.state).toBe('working') + await loaded.runtime.refreshHostedRooms() + expect(stateCalls).toBe(2) + expect(loaded.chat.$groupChats.get().Release.hostedStatus?.state).toBe('ready') + loaded.runtime.stopHostedRoomRuntime() + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts new file mode 100644 index 0000000000000..4cdd4372fae9b --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts @@ -0,0 +1,1752 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as groupChat from './group-chat' +import type * as groupRounds from './group-rounds' +import { pluginSdkMock, scriptedStorage } from './group-test-utils' +import type * as hostedRuntime from './hosted-room-runtime' +import type { GroupChat, GroupMember } from './types' + +const { host } = vi.hoisted(() => ({ + host: {} as Record +})) + +vi.mock('@hermes/plugin-sdk', async () => pluginSdkMock(host)) + +interface RpcCall { + connectionId?: string + method: string + params: Record +} + +interface RuntimeRoom { + chat: typeof groupChat + calls: RpcCall[] + rounds: typeof groupRounds + runtime: typeof hostedRuntime + storage: Map +} + +const MEMBERS: GroupMember[] = [ + { + name: 'research', + connectionId: 'gateway-a', + sourceScoped: true, + targetProfile: 'research' + }, + { + name: 'builder', + connectionId: 'gateway-a', + sourceScoped: true, + targetProfile: 'builder' + } +] + +function room(overrides: Partial = {}): GroupChat { + return { + log: [], + watermarks: {}, + members: MEMBERS, + roomId: 'room-1', + hosted: 'install:home', + hostedEpoch: 1, + hostedConnectionId: 'gateway-a', + hostedSeq: 0, + continuityMode: 'gateway', + ...overrides + } +} + +function hostedEvent( + seq: number, + eventId: string, + kind: string, + payload: Record = {}, + actor: Record = { + kind: 'gateway', + id: 'install:home' + } +) { + return { + room_id: 'room-1', + seq, + event_id: eventId, + kind, + actor, + payload, + created_at: seq + } +} + +async function loadRuntime( + handler: ( + method: string, + params: Record, + route?: Record + ) => Promise | unknown, + routes: Array> = [ + { + connectionId: 'gateway-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'default' + } + ] +): Promise { + vi.resetModules() + const calls: RpcCall[] = [] + const storage = new Map() + + for (const key of Object.keys(host)) { + delete host[key] + } + + Object.assign(host, { + activeConnectionId: () => 'gateway-a', + notify: vi.fn(), + profileRoutes: async () => routes, + request: async (method: string, params: Record) => { + calls.push({ + method, + params + }) + + return handler(method, params) + }, + requestProfile: async (route: Record, method: string, params: Record) => { + calls.push({ + connectionId: String(route?.connectionId || ''), + method, + params + }) + + return handler(method, params, route) + }, + state: { + connectionId: { + get: () => 'gateway-a', + listen: () => () => undefined + }, + gateway: { + get: () => 'open', + listen: () => () => undefined + }, + profile: { + get: () => 'default', + listen: () => () => undefined + } + } + }) + + const [chat, rounds, runtime, shared] = await Promise.all([ + import('./group-chat'), + import('./group-rounds'), + import('./hosted-room-runtime'), + import('./shared') + ]) + + shared.setPluginCtx(scriptedStorage(storage)) + + return { + chat, + calls, + rounds, + runtime, + storage + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +describe('hosted Group Chat runtime', () => { + it('hydrates after local state, reconciles optimistic ids, and replays one contiguous gateway log', async () => { + const events = [ + hostedEvent(1, 'created-1', 'room.created', { + name: 'Release', + members: [ + { + member_id: 'research', + profile: 'research' + }, + { + member_id: 'builder', + profile: 'builder' + } + ] + }), + hostedEvent( + 2, + 'user-1', + 'message.user', + { + text: 'Start', + thread_id: 'thread-1' + }, + { + kind: 'user', + id: 'desktop' + } + ), + hostedEvent( + 3, + 'member-1', + 'message.member', + { + text: 'Done', + thread_id: 'thread-1' + }, + { + kind: 'member', + id: 'builder', + display_name: 'Builder' + } + ), + hostedEvent(4, 'settled-1', 'turn.settled') + ] + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home', + max_log_limit: 100 + } + } + + if (method === 'groups.list') { + return { + rooms: [ + { + room_id: 'room-1', + name: 'Release', + members: MEMBERS, + authority_gateway_id: 'install:home', + authority_epoch: 1, + disbanded_at: null + } + ] + } + } + + if (method === 'groups.state') { + return { + room: { + room_id: 'room-1', + name: 'Release', + members: [ + { + member_id: 'research', + profile: 'research' + }, + { + member_id: 'builder', + profile: 'builder' + } + ], + authority_gateway_id: 'install:home', + authority_epoch: 1, + disbanded_at: null + }, + driver_status: { + working: false + } + } + } + + if (method === 'groups.log') { + return { + events, + latest_seq: 4, + has_more: false + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room({ + log: [ + { + at: 2, + from: { + kind: 'user', + name: 'You' + }, + id: 'user-1', + text: 'Start', + thread: 'thread-1' + } + ] + }) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const hydrated = loaded.chat.$groupChats.get().Release + + expect(hydrated.continuityMode).toBe('gateway') + expect(hydrated.hostedSeq).toBe(4) + expect(hydrated.log.map(entry => [entry.seq, entry.id, entry.text])).toEqual([ + [2, 'user-1', 'Start'], + [3, 'member-1', 'Done'] + ]) + expect(hydrated.log.filter(entry => entry.id === 'user-1')).toHaveLength(1) + expect(hydrated.running).toBe(false) + expect(hydrated.hostedStatus).toMatchObject({ + state: 'ready', + label: 'Ready' + }) + expect(loaded.storage.get('group-chats')).toBeTruthy() + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('replays the final events of a known room before marking a remote disband', async () => { + const events = [ + hostedEvent(3, 'member-1', 'message.member', { + text: 'Finished while Desktop was closed', + thread_id: 'thread-1' + }, { + kind: 'member', + id: 'builder', + display_name: 'Builder' + }), + hostedEvent(4, 'disbanded-1', 'room.disbanded') + ] + + const loaded = await loadRuntime((method, params) => { + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:home', + driver: true, + max_log_limit: 1, + persistent_process: true + } + } + + if (method === 'groups.list') { + return { + rooms: [{ + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + }] + } + } + + if (method === 'groups.state') { + expect(params.include_disbanded).toBe(true) + + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + expect(params.include_disbanded).toBe(true) + expect(params.limit).toBe(1) + const since = Number(params.since_seq) + + return { + events: events.filter(event => event.seq > since).slice(0, 1), + has_more: since < 3, + latest_seq: 4 + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room({ + hostedSeq: 2, + log: [{ + at: 2, + from: { kind: 'user', name: 'You' }, + id: 'user-1', + seq: 2, + text: 'Start', + thread: 'thread-1' + }] + }) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.chat.$groupChats.get().Release).toMatchObject({ + hostedSeq: 4, + hostedStatus: { state: 'deleted' }, + continuityIssue: 'Delete it here to remove its local membership and history.' + }) + expect(loaded.chat.$groupChats.get().Release.log.map(entry => entry.text)).toEqual([ + 'Start', + 'Finished while Desktop was closed' + ]) + loaded.runtime.stopHostedRoomRuntime() + }) + + it.each(['state', 'log'] as const)( + 'does not paint a remote disband before terminal %s recovery succeeds', + async failurePoint => { + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { authority_gateway_id: 'install:home', driver: true, persistent_process: true } + } + + if (method === 'groups.list') { + return { + rooms: [{ + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + }] + } + } + + if (method === 'groups.state') { + if (failurePoint === 'state') { + throw new Error('temporary state failure') + } + + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + throw new Error('temporary log failure') + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room({ + hostedSeq: 2, + log: [{ + at: 2, + from: { kind: 'user', name: 'You' }, + id: 'user-1', + seq: 2, + text: 'Start', + thread: 'thread-1' + }] + }) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const unresolved = loaded.chat.$groupChats.get().Release + + expect(unresolved.hostedSeq).toBe(2) + expect(unresolved.hostedStatus?.state).not.toBe('deleted') + expect(unresolved.log.map(entry => entry.text)).toEqual(['Start']) + loaded.runtime.stopHostedRoomRuntime() + } + ) + + it('does not materialize a remotely disbanded room that this client never joined', async () => { + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { authority_gateway_id: 'install:home', driver: true, persistent_process: true } + } + + if (method === 'groups.list') { + return { + rooms: [{ + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + }] + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.chat.$groupChats.get()).toEqual({}) + expect(loaded.calls.some(call => call.method === 'groups.state')).toBe(false) + expect(loaded.calls.some(call => call.method === 'groups.log')).toBe(false) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('reconciles peer members without rewriting them onto the home gateway', async () => { + const routes = [ + { connectionId: 'gateway-b', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'gateway-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' } + ] + + const serverMembers = [ + { + handle: 'research', + member_id: 'member-1-research', + profile: 'research', + target: { kind: 'local', profile: 'research' } + }, + { + handle: 'builder', + member_id: 'member-2-builder', + profile: 'builder', + target: { + installation_id: 'install:gateway-b', + kind: 'peer', + peer_id: 'install:gateway-b', + profile: 'builder' + } + } + ] + + const loaded = await loadRuntime((method, _params, route) => { + const connectionId = String(route?.connectionId || '') + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: `install:${connectionId}`, + driver: true, + features: ['reciprocal_room_control'], + persistent_process: true + } + } + + if (method === 'groups.list') { + return { + rooms: + connectionId === 'gateway-a' + ? [ + { + authority_epoch: 1, + authority_gateway_id: 'install:gateway-a', + disbanded_at: null, + members: serverMembers, + name: 'Distributed', + room_id: 'room-1' + } + ] + : [] + } + } + + if (method === 'groups.state') { + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:gateway-a', + disbanded_at: null, + members: serverMembers, + name: 'Distributed', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + return { events: [], has_more: false, latest_seq: 0 } + } + + if (method === 'groups.control.invite') { + return { + authority_epoch: 1, + authority_gateway_id: 'install:gateway-a', + control_token: 'private-control-token', + expires_at: 253_402_300_799, + home_url: 'https://gateway-a.example.test:19445', + member_count: 2, + room_name: 'Distributed' + } + } + + if (method === 'groups.control.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }, routes) + + loaded.chat.$groupChats.set({ + Distributed: room({ + continuityMode: 'distributed', + members: [ + { connectionId: 'gateway-a', handle: 'research', name: 'research', sourceScoped: true }, + { connectionId: 'gateway-b', handle: 'builder', name: 'builder', sourceScoped: true } + ] + }) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const reconciled = loaded.chat.$groupChats.get().Distributed + + expect(reconciled.continuityMode).toBe('distributed') + expect(reconciled.members).toEqual([ + expect.objectContaining({ connectionId: 'gateway-a', name: 'research' }), + expect.objectContaining({ connectionId: 'gateway-b', name: 'builder' }) + ]) + await vi.waitFor(() => { + expect(loaded.calls.find(call => call.method === 'groups.control.register')).toMatchObject({ + connectionId: 'gateway-b', + params: { + member_id: 'member-2-builder', + profile: 'builder', + room_id: 'room-1' + } + }) + }) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('degrades an old gateway without starting the classic Desktop round driver', async () => { + const missing = Object.assign(new Error('method not found'), { + code: -32601 + }) + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities' || method === 'groups.send') { + throw missing + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Legacy: room({ + log: [ + { + at: 1, + from: { + kind: 'user', + name: 'You' + }, + id: 'legacy-1', + text: 'Keep going', + thread: 'thread-1' + } + ] + }) + }) + + const localProjection = loaded.chat.groupChatSyncSnapshot(loaded.chat.$groupChats.get()) + + const mergedProjection = loaded.chat.mergeGroupChatSyncSnapshots( + { + version: 3, + rooms: { + 'id:room-1': { + name: 'Legacy', + roomId: 'room-1', + log: [], + revision: 9, + hosted: 'install:untrusted-projection', + hostedEpoch: 9, + continuityMode: 'gateway' + } + } + }, + localProjection + ) + + expect(mergedProjection.rooms['id:room-1']).toMatchObject({ + hosted: 'install:home', + hostedEpoch: 1, + continuityMode: 'gateway' + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.chat.$groupChats.get().Legacy).toMatchObject({ + hosted: 'install:home', + continuityMode: 'gateway', + running: false, + hostedStatus: { + state: 'unsupported', + label: 'Update this device to keep this Group Chat running.' + } + }) + + const thread = loaded.rounds.sendToGroupChat('Legacy', MEMBERS, 'Continue', null, []) + + expect(thread).toBeTruthy() + await Promise.resolve() + await Promise.resolve() + expect(loaded.calls.some(call => call.method === 'session.create' || call.method === 'prompt.submit')).toBe(false) + expect(loaded.chat.$groupChats.get().Legacy.hosted).toBe('install:home') + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('persists send, stop, and disband commands before dispatch and acknowledges them idempotently', async () => { + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home' + } + } + + if (method === 'groups.list') { + return { + rooms: [] + } + } + + if (method === 'groups.create') { + return { + room: { + room_id: 'room-new', + authority_gateway_id: 'install:home', + authority_epoch: 1 + } + } + } + + if (method === 'groups.send' || method === 'groups.stop' || method === 'groups.disband') { + return { + ok: true + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room() + }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const probe = await loaded.runtime.probeHostedRoomMembers(MEMBERS) + + await expect( + loaded.runtime.createHostedGroupChat({ + route: probe.route, + roomId: 'room-new', + name: 'New Group', + members: MEMBERS.map(member => ({ + member_id: member.name, + profile: member.name, + handle: member.name + })) + }) + ).resolves.toEqual({ + authorityId: 'install:home', + authorityEpoch: 1, + connectionId: 'gateway-a' + }) + + await expect( + loaded.runtime.sendHostedGroupChat( + 'Release', + { + at: 1, + from: { + kind: 'user', + name: 'You' + }, + id: 'send-1', + text: 'Ship it', + thread: 'thread-1' + }, + 'thread-1' + ) + ).resolves.toBe(true) + await expect(loaded.runtime.stopHostedGroupChat('Release')).resolves.toBe(true) + await expect(loaded.runtime.disbandHostedGroupChat('Release')).resolves.toBe(true) + + expect(loaded.calls.map(call => call.method)).toEqual( + expect.arrayContaining(['groups.create', 'groups.send', 'groups.stop', 'groups.disband']) + ) + expect((loaded.storage.get('hosted-room-outbox-v1') as { commands: unknown[] }).commands).toEqual([]) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('confirms an unknown create outcome before allowing Desktop fallback', async () => { + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home' + } + } + + if (method === 'groups.list') { + return { + rooms: [] + } + } + + if (method === 'groups.create') { + throw new Error('response lost') + } + + if (method === 'groups.state') { + return { + room: { + room_id: 'room-new', + name: 'New Group', + authority_gateway_id: 'install:home', + authority_epoch: 1 + } + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + const probe = await loaded.runtime.probeHostedRoomMembers(MEMBERS) + + await expect( + loaded.runtime.createHostedGroupChat({ + route: probe.route, + roomId: 'room-new', + name: 'New Group', + members: MEMBERS.map(member => ({ + member_id: member.name, + profile: member.name, + handle: member.name + })) + }) + ).resolves.toEqual({ + authorityId: 'install:home', + authorityEpoch: 1, + connectionId: 'gateway-a' + }) + expect(loaded.calls.map(call => call.method)).toEqual(expect.arrayContaining(['groups.create', 'groups.state'])) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('retries a hosted rename with the same idempotency key', async () => { + let renameAttempts = 0 + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home' + } + } + + if (method === 'groups.list') { + return { + rooms: [] + } + } + + if (method === 'groups.rename') { + renameAttempts += 1 + + if (renameAttempts === 1) { + throw new Error('connection closed') + } + + return { + room: { + room_id: 'room-1', + name: 'Renamed' + } + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room() + }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const staleRefreshGeneration = loaded.runtime.beginHostedRoomMutation('room-1') + + await expect(loaded.runtime.renameHostedGroupChat('Release', 'Renamed')).resolves.toBe(false) + + expect(loaded.runtime.hostedRoomMutationIsCurrent('room-1', staleRefreshGeneration)).toBe(false) + + const pending = loaded.storage.get('hosted-room-outbox-v1') as { + commands: Array<{ commandId: string; kind: string; status: string }> + } + + expect(pending.commands).toEqual([ + expect.objectContaining({ + kind: 'rename', + status: 'pending' + }) + ]) + + await loaded.runtime.dispatchHostedRoomOutbox() + + const calls = loaded.calls.filter(call => call.method === 'groups.rename') + + expect(calls).toHaveLength(2) + expect(calls[0].params.event_id).toBe(calls[1].params.event_id) + expect(calls[1].params).toMatchObject({ + room_id: 'room-1', + name: 'Renamed' + }) + expect(loaded.storage.get('hosted-room-outbox-v1')).toMatchObject({ + commands: [] + }) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('replays an unknown in-flight send after Desktop closes with the same command id', async () => { + let releaseFirstSend: () => void = () => undefined + let firstSend = true + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home' + } + } + + if (method === 'groups.list') { + return { + rooms: [] + } + } + + if (method === 'groups.send' && firstSend) { + firstSend = false + + return new Promise(resolve => { + releaseFirstSend = () => + resolve({ + ok: true + }) + }) + } + + if (method === 'groups.send') { + return { + ok: true + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room() + }) + const storage = scriptedStorage(loaded.storage).storage + + await loaded.runtime.startHostedRoomRuntime(storage) + + const delivery = loaded.runtime.sendHostedGroupChat( + 'Release', + { + at: 1, + from: { + kind: 'user', + name: 'You' + }, + id: 'send-after-close', + text: 'Keep working', + thread: 'thread-1' + }, + 'thread-1' + ) + + for (let attempt = 0; attempt < 10; attempt++) { + await Promise.resolve() + } + + expect(loaded.storage.get('hosted-room-outbox-v1')).toMatchObject({ + commands: [ + { + commandId: 'send-after-close', + status: 'in-flight' + } + ] + }) + + loaded.runtime.stopHostedRoomRuntime() + releaseFirstSend() + await expect(delivery).resolves.toBe(false) + + await loaded.runtime.startHostedRoomRuntime(storage) + + expect(loaded.calls.filter(call => call.method === 'groups.send')).toHaveLength(2) + expect(loaded.storage.get('hosted-room-outbox-v1')).toMatchObject({ + commands: [] + }) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it.each(['send', 'disband'] as const)( + 'replays a persisted %s after the hosted worker recovers from 4123', + async kind => { + let available = false + let accepted = 0 + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + driver: true, + persistent_process: true, + authority_gateway_id: 'install:home' + } + } + + if (method === 'groups.list') { + return { rooms: [] } + } + + if (method === `groups.${kind}`) { + if (!available) { + throw Object.assign(new Error('Group Chat worker is unavailable'), { + code: 4123 + }) + } + + accepted += 1 + + return { ok: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ Release: room() }) + const storage = scriptedStorage(loaded.storage).storage + + await loaded.runtime.startHostedRoomRuntime(storage) + + const submitted = + kind === 'send' + ? await loaded.runtime.sendHostedGroupChat( + 'Release', + { + at: 1, + from: { kind: 'user', name: 'You' }, + id: 'worker-restart-send', + text: 'Keep working', + thread: 'thread-1' + }, + 'thread-1' + ) + : await loaded.runtime.disbandHostedGroupChat('Release') + + expect(submitted).toBe(false) + + const persisted = loaded.storage.get('hosted-room-outbox-v1') as { + commands: Array<{ commandId: string; status: string }> + } + + expect(persisted.commands).toHaveLength(1) + expect(persisted.commands[0].status).toBe('pending') + + const commandId = persisted.commands[0].commandId + + loaded.runtime.stopHostedRoomRuntime() + available = true + await loaded.runtime.startHostedRoomRuntime(storage) + + const calls = loaded.calls.filter(call => call.method === `groups.${kind}`) + + expect(calls).toHaveLength(2) + expect( + calls.map(call => String(call.params.event_id || call.params.cancel_id || '')) + ).toEqual([commandId, commandId]) + expect(accepted).toBe(1) + expect(loaded.storage.get('hosted-room-outbox-v1')).toMatchObject({ + commands: [] + }) + + loaded.runtime.stopHostedRoomRuntime() + } + ) + + it.each([true, false])( + 'creates a multi-host Group Chat with target-issued scoped grants (reciprocal control: %s)', + async reciprocalControl => { + const routes = [ + { connectionId: 'host-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'host-b', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'host-b', mode: 'remote' as const, profile: 'builder', targetProfile: 'builder' } + ] + + const loaded = await loadRuntime((method, _params, route) => { + const connectionId = String(route?.connectionId || '') + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: `install:${connectionId}`, + driver: true, + persistent_process: true, + features: reciprocalControl ? ['reciprocal_room_control'] : [], + room_link: { + enabled: true, + endpoint: { + available: true, + url: `https://${connectionId}.example.test:19445` + }, + catalog: { + attachments: false, + catalog_digest: `digest:${connectionId}`, + installation_id: `install:${connectionId}`, + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.list') { + return { rooms: [] } + } + + if (method === 'groups.peer.invite') { + return { + grant: 'grant:builder', + target_profile: 'builder', + catalog: { + attachments: false, + catalog_digest: 'digest:host-b', + installation_id: 'install:host-b', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + + if (method === 'groups.create') { + return { + room: { + authority_epoch: 1, + authority_gateway_id: 'install:host-a', + room_id: 'room-multi' + } + } + } + + if (method === 'groups.control.invite') { + return { + authority_epoch: 1, + authority_gateway_id: 'install:host-a', + control_token: 'private-control-token', + expires_at: 2_000_000_000, + home_url: 'https://host-a.example.test:19445', + member_count: 2, + room_name: 'Multi' + } + } + + if (method === 'groups.control.register') { + return { registered: true } + } + + if (method === 'groups.peer.register') { + return { registered: true } + } + + throw new Error(`unexpected method: ${method}`) + }, routes) + + const storage = scriptedStorage(loaded.storage).storage + + await loaded.runtime.startHostedRoomRuntime(storage) + + const members: GroupMember[] = [ + { + connectionId: 'host-a', + name: 'research', + route: routes[0], + sourceScoped: true, + targetProfile: 'research' + }, + { + connectionId: 'host-b', + name: 'builder', + route: routes[2], + sourceScoped: true, + targetProfile: 'builder' + } + ] + + const probe = await loaded.runtime.probeHostedRoomMembers(members) + + expect(probe.route).toMatchObject({ + homeConnectionId: 'host-a', + kind: 'multi-gateway', + remoteConnectionIds: ['host-b'] + }) + await expect( + loaded.runtime.createAutonomousHostedGroupChat({ + members: [ + { handle: 'research', member: members[0], profile: 'research' }, + { handle: 'builder', member: members[1], profile: 'builder' } + ], + name: 'Multi', + probe, + roomId: 'room-multi' + }) + ).resolves.toMatchObject({ + authorityId: 'install:host-a', + connectionId: 'host-a', + continuityMode: 'distributed' + }) + + expect(loaded.calls.find(call => call.method === 'groups.peer.invite')?.connectionId).toBe('host-b') + expect(loaded.calls.find(call => call.method === 'groups.create')?.connectionId).toBe('host-a') + expect(loaded.calls.find(call => call.method === 'groups.peer.register')?.params).toMatchObject({ + grant: 'grant:builder', + member_id: 'member-2-builder', + room_id: 'room-multi', + target_profile: 'builder', + target_url: 'https://host-b.example.test:19445/p/builder' + }) + + if (reciprocalControl) { + expect(loaded.calls.find(call => call.method === 'groups.control.register')?.params).toMatchObject({ + authority_gateway_id: 'install:host-a', + member_count: 2, + member_id: 'member-2-builder', + profile: 'builder', + room_id: 'room-multi', + room_name: 'Multi' + }) + } else { + expect(loaded.calls.some(call => call.method.startsWith('groups.control.'))).toBe(false) + } + + expect((loaded.storage.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + + loaded.runtime.stopHostedRoomRuntime() + } + ) + + it('durably disbands and revokes a partial multi-host setup', async () => { + const routes = [ + { connectionId: 'host-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'host-b', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'host-b', mode: 'remote' as const, profile: 'builder', targetProfile: 'builder' } + ] + + let cleanupAvailable = false + + const loaded = await loadRuntime((method, _params, route) => { + const connectionId = String(route?.connectionId || '') + + if (method === 'groups.capabilities') { + return { + authority_gateway_id: `install:${connectionId}`, + driver: true, + persistent_process: true, + room_link: { + enabled: true, + endpoint: { available: true, url: `https://${connectionId}.example.test:19445` }, + catalog: { + attachments: false, + catalog_digest: `digest:${connectionId}`, + installation_id: `install:${connectionId}`, + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.list') { + return { rooms: [] } + } + + if (method === 'groups.peer.invite') { + return { + grant: 'grant:builder', + target_profile: 'builder', + catalog: { + attachments: false, + catalog_digest: 'digest:host-b', + installation_id: 'install:host-b', + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + + if (method === 'groups.create' || method === 'groups.state') { + throw new Error('create failed') + } + + if (method === 'groups.disband' || method === 'groups.peer.revoke') { + if (!cleanupAvailable) { + throw new Error('device offline') + } + + return { ok: true } + } + + throw new Error(`unexpected method: ${method}`) + }, routes) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + const members: GroupMember[] = [ + { connectionId: 'host-a', name: 'research', route: routes[0], sourceScoped: true, targetProfile: 'research' }, + { connectionId: 'host-b', name: 'builder', route: routes[2], sourceScoped: true, targetProfile: 'builder' } + ] + + const probe = await loaded.runtime.probeHostedRoomMembers(members) + + const failure = await loaded.runtime + .createAutonomousHostedGroupChat({ + members: [ + { handle: 'research', member: members[0], profile: 'research' }, + { handle: 'builder', member: members[1], profile: 'builder' } + ], + name: 'Partial', + probe, + roomId: 'room-partial' + }) + .catch(error => error as Error & { fallbackSafe?: boolean }) + + expect(failure).toMatchObject({ + fallbackSafe: false, + message: expect.stringContaining('could not finish cleanup') + }) + expect((loaded.storage.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).not.toEqual([]) + + cleanupAvailable = true + loaded.runtime.stopHostedRoomRuntime() + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.calls.map(call => call.method)).toEqual( + expect.arrayContaining(['groups.disband', 'groups.peer.revoke']) + ) + expect((loaded.storage.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + + loaded.runtime.stopHostedRoomRuntime() + }) + + it('reprobes deterministic unsupported gateways only after the 30-second cache expires', async () => { + vi.setSystemTime(100) + let probes = 0 + const missing = Object.assign(new Error('method not found'), { code: -32601 }) + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + probes += 1 + throw missing + } + + throw new Error(`unexpected method: ${method}`) + }) + + await loaded.runtime.probeHostedRoomMembers(MEMBERS) + await loaded.runtime.probeHostedRoomMembers(MEMBERS) + expect(probes).toBe(1) + + vi.setSystemTime(30_101) + await loaded.runtime.probeHostedRoomMembers(MEMBERS) + expect(probes).toBe(2) + }) + + it('keeps a projection-only room read-only until its member gateway inventory settles', async () => { + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:home', + driver: true, + persistent_process: true + } + } + + if (method === 'groups.list') { + return { rooms: [] } + } + + throw new Error(`unexpected method: ${method}`) + }) + + const projected = room({ + hosted: null, + hostedConnectionId: null, + hostedEpoch: null, + continuityMode: 'desktop', + members: MEMBERS.map(member => ({ ...member, remoteSource: true })) + }) + + loaded.chat.$groupChats.set({ Projected: projected }) + expect(loaded.runtime.groupChatContinuityReady(projected)).toBe(false) + expect(loaded.rounds.sendToGroupChat('Projected', projected.members || [], 'Do not double-drive')).toBeNull() + expect(loaded.chat.$groupChats.get().Projected.continuityIssue).toBe('Syncing recent activity…') + expect(loaded.calls.some(call => call.method === 'session.create' || call.method === 'prompt.submit')).toBe(false) + expect( + loaded.runtime.groupChatContinuityReady({ + ...projected, + members: MEMBERS + }) + ).toBe(true) + + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.runtime.groupChatContinuityReady(projected)).toBe(true) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('keeps a remotely deleted room read-only without painting a local send', async () => { + const loaded = await loadRuntime(method => { + throw new Error(`deleted room must not dispatch: ${method}`) + }) + const deleted = room({ + hostedStatus: { label: 'Deleted', state: 'deleted' }, + running: false + }) + + loaded.chat.$groupChats.set({ Deleted: deleted }) + expect(loaded.runtime.groupChatContinuityReady(deleted)).toBe(false) + expect(loaded.rounds.sendToGroupChat('Deleted', MEMBERS, 'must not paint')).toBeNull() + expect(loaded.chat.$groupChats.get().Deleted.log).toEqual([]) + expect(loaded.calls.some(call => call.method === 'groups.send')).toBe(false) + }) + + it('does not enqueue another Stop while a hosted Stop is already pending', async () => { + const loaded = await loadRuntime(method => { + throw new Error(`stopping room must not dispatch: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Stopping: room({ + hostedStatus: { canStop: false, label: 'Stopping…', state: 'stopping' }, + running: true + }) + }) + await loaded.rounds.stopGroupThread('Stopping', null, MEMBERS) + + expect(loaded.calls.some(call => call.method === 'groups.stop')).toBe(false) + expect(loaded.chat.$groupChats.get().Stopping.hostedStatus?.state).toBe('stopping') + }) + + it.each(['send', 'stop'] as const)('does not let a stale replay overwrite a newer hosted %s', async action => { + let exposeRoom = false + let releaseLog: () => void = () => undefined + let logStarted: () => void = () => undefined + + const logRequested = new Promise(resolve => { + logStarted = resolve + }) + + const heldLog = new Promise>(resolve => { + releaseLog = () => resolve({ events: [], has_more: false, latest_seq: 0 }) + }) + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:home', + driver: true, + persistent_process: true + } + } + + if (method === 'groups.list') { + return { + rooms: exposeRoom + ? [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: 0, + members: MEMBERS, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + : [] + } + } + + if (method === 'groups.state') { + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + logStarted() + + return heldLog + } + + if (method === 'groups.send' || method === 'groups.stop') { + return { ok: true } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ + Release: room({ + hostedStatus: { label: 'Working', state: 'working' }, + running: true + }) + }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + exposeRoom = true + + const refresh = loaded.runtime.refreshHostedRooms() + await logRequested + + if (action === 'send') { + expect(loaded.rounds.sendToGroupChat('Release', MEMBERS, 'Keep going')).toBeTruthy() + } else { + await loaded.rounds.stopGroupThread('Release', null, MEMBERS) + } + + for (let attempt = 0; attempt < 10; attempt += 1) { + await Promise.resolve() + } + + const expectedState = loaded.chat.$groupChats.get().Release.hostedStatus?.state + + if (action === 'send') { + expect(['sending', 'working']).toContain(expectedState) + } else { + expect(expectedState).toBe('stopped') + } + + releaseLog() + await refresh + + expect(loaded.chat.$groupChats.get().Release.hostedStatus?.state).toBe(expectedState) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('continues a bounded partial replay on the next refresh and offers Retry while incomplete', async () => { + const events = Array.from({ length: 21 }, (_, index) => + hostedEvent(index + 1, `message-${index + 1}`, 'message.user', { + text: `Message ${index + 1}`, + thread_id: 'thread-1' + }) + ) + + const loaded = await loadRuntime((method, params) => { + if (method === 'groups.capabilities') { + return { + authority_gateway_id: 'install:home', + driver: true, + max_log_limit: 1, + persistent_process: true + } + } + + if (method === 'groups.list') { + return { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + latest_seq: events.length, + members: MEMBERS, + name: 'Release', + revision: 1, + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + const since = Number(params.since_seq || 0) + + return { + events: events.slice(since, since + 1), + has_more: since + 1 < events.length, + latest_seq: events.length + } + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ Release: room() }) + await loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + + expect(loaded.chat.$groupChats.get().Release).toMatchObject({ + hostedSeq: 20, + hostedStatus: { canRetry: true }, + continuityIssue: 'Syncing recent activity…' + }) + + await loaded.runtime.refreshHostedRooms() + + expect(loaded.chat.$groupChats.get().Release).toMatchObject({ + hostedSeq: 21, + continuityIssue: null + }) + expect(loaded.calls.filter(call => call.method === 'groups.log')).toHaveLength(21) + expect(loaded.calls.filter(call => call.method === 'groups.log').every(call => call.params.limit === 1)).toBe(true) + loaded.runtime.stopHostedRoomRuntime() + }) + + it('does not resurrect an idle room deleted while replay is in flight', async () => { + let releaseLog: () => void = () => undefined + let logStarted: () => void = () => undefined + + const logRequested = new Promise(resolve => { + logStarted = resolve + }) + + const heldLog = new Promise>(resolve => { + releaseLog = () => resolve({ events: [], has_more: false, latest_seq: 0 }) + }) + + const loaded = await loadRuntime(method => { + if (method === 'groups.capabilities') { + return { authority_gateway_id: 'install:home', driver: true, persistent_process: true } + } + + if (method === 'groups.list') { + return { + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + ] + } + } + + if (method === 'groups.state') { + return { + driver_status: { working: false }, + room: { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: null, + members: MEMBERS, + name: 'Release', + room_id: 'room-1' + } + } + } + + if (method === 'groups.log') { + logStarted() + + return heldLog + } + + throw new Error(`unexpected method: ${method}`) + }) + + loaded.chat.$groupChats.set({ Release: room({ running: false }) }) + const refresh = loaded.runtime.startHostedRoomRuntime(scriptedStorage(loaded.storage).storage) + await logRequested + + loaded.runtime.markHostedRoomLocallyDeleted('room-1') + loaded.chat.$groupChats.set({}) + releaseLog() + await refresh + + expect(loaded.chat.$groupChats.get().Release).toBeUndefined() + loaded.runtime.stopHostedRoomRuntime() + }) + + it('separates queued work from active work and fingerprints idle room state', async () => { + const loaded = await loadRuntime(() => ({})) + + expect( + loaded.runtime.hostedRoomDriverDisplayStatus({ kind: 'ready' }, { counts: { queued: 1 }, working: false }) + ).toMatchObject({ kind: 'queued', canStop: true }) + expect( + loaded.runtime.hostedRoomDriverDisplayStatus( + { kind: 'needs-attention' }, + { counts: { stopping: 1 }, working: true } + ) + ).toMatchObject({ kind: 'stopping', canStop: false }) + expect(loaded.runtime.hostedRoomPollFingerprint({ revision: 4, latest_seq: 9 })).toBe('4:9') + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts new file mode 100644 index 0000000000000..917e9c1256f8a --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts @@ -0,0 +1,1866 @@ +/** + * Gateway-hosted Group Chat runtime. + * + * RPC ownership lives here: capability negotiation, the durable command + * outbox, monotonic replay, and the bounded refresh loop. Group state remains + * owned by `group-chat.ts`; creation, round routing, and room UI call this + * module through narrow verbs. + */ + +import { atom, host } from '@hermes/plugin-sdk' +import type { PluginContext } from '@hermes/plugin-sdk' + +import { $lastRoster } from './data' +import { + $groupChats, + applyHostedRoomAuthority, + groupChatHostedGateway, + mergeGroupChatSyncEntries, + uniqueGroupChatName, + updateGroupChat +} from './group-chat' +import { + addHostedRoomCleanup, + armHostedRoomCleanup, + dispatchHostedRoomCleanup, + hostedRoomCleanupPending, + releaseHostedRoomCleanup, + resetHostedRoomCleanupForTests, + startHostedRoomCleanup, + stopHostedRoomCleanup +} from './hosted-room-cleanup' +import { + classifyHostedRoomCapability, + createHostedRoomOutbox, + createHostedRoomReplayState, + deriveFriendlyHostedRoomStatus, + isHostedRoomContinuityEligible, + profileScopedRoomLinkEndpoint, + reduceHostedRoomOutbox, + replayHostedRoomPages, + resolveAutonomousRoomPlan +} from './hosted-room-client' +import type { + AutonomousRoomPlan, + FriendlyHostedRoomStatus, + HostedRoomCapability, + HostedRoomCommand, + HostedRoomOutbox, + HostedRoomRouteResolution +} from './hosted-room-client' +import { botsText } from './i18n' +import { requestForBot } from './routing' +import type { GroupChat, GroupMember, GroupMessage, ProfileRoute } from './types' + +export { $hostedRoomCleanup } from './hosted-room-cleanup' +export { describeAutonomousRoomPlan, describeHostedRoomCreationError } from './hosted-room-client' + +const HOSTED_ROOM_OUTBOX_KEY = 'hosted-room-outbox-v1' +const HOSTED_ROOM_LIST_PAGE_SIZE = 500 +const HOSTED_ROOM_LIST_MAX_PAGES = 4 +const HOSTED_ROOM_SYNC_INTERVAL_MS = 5000 +const HOSTED_ROOM_UNSUPPORTED_REPROBE_MS = 30_000 + +export const $hostedRoomCapabilities = atom>({}) +export const $hostedRoomOutbox = atom(createHostedRoomOutbox()) + +const hostedAuthorityRoutes = new Map() +const hostedRoomPollCache = new Map() +const hostedRoomPollGenerations = new Map() +const hostedRoomMutationGenerations = new Map() +const hostedRoomLocallyDeleted = new Set() +const hostedRoomInventoriedConnections = new Set() +const hostedRoomControlEnrolled = new Set() +const hostedRoomControlPending = new Map() +const hostedRoomControlRetryAfter = new Map() +let hostedRoomControlQueue: Promise = Promise.resolve() +let hostedRoomControlGeneration = 0 +let hostedRoomSyncTimer: ReturnType | null = null +let hostedRoomSyncRunning = false +let hostedRoomSyncDisposed = true +let hostedRoomLifecycleGeneration = 0 +let hostedOutboxDispatching = false +let hostedRoomStorage: null | PluginContext['storage'] = null +let hostedRoomHooks: HostedRoomRuntimeHooks = {} +const hostedUnsupportedUntil = new Map() + +export function hostedRoomLifecycleToken() { + return hostedRoomLifecycleGeneration +} + +export function hostedRoomLifecycleIsCurrent(token: number) { + return !hostedRoomSyncDisposed && token === hostedRoomLifecycleGeneration +} + +function hostedRoomMutationGeneration(roomId: string) { + return Math.max(0, Number(hostedRoomMutationGenerations.get(String(roomId || '')) || 0)) +} + +/** Fence an asynchronous local send/Stop/delete against an older replay. */ +export function beginHostedRoomMutation(roomId: string) { + const id = String(roomId || '') + const generation = hostedRoomMutationGeneration(id) + 1 + + if (id) { + hostedRoomMutationGenerations.set(id, generation) + } + + return generation +} + +export function hostedRoomMutationIsCurrent(roomId: string, generation: number) { + const id = String(roomId || '') + + return Boolean(id) && !hostedRoomLocallyDeleted.has(id) && hostedRoomMutationGeneration(id) === generation +} + +/** Keep an acknowledged local deletion invisible to stale in-flight polls. */ +export function markHostedRoomLocallyDeleted(roomId: string) { + const id = String(roomId || '') + + if (!id) { + return + } + + beginHostedRoomMutation(id) + hostedRoomLocallyDeleted.add(id) + hostedRoomPollCache.delete(id) +} + +/** A projection-only room must not start a classic Desktop driver until each + * member gateway has been inventoried. Existing local classic rooms carry + * either a Desktop authority or non-projected member descriptors and remain + * immediately usable. */ +export function groupChatContinuityReady(room: GroupChat | null | undefined) { + if (!room) { + return true + } + + if (groupChatHostedGateway(room)) { + return room.hostedStatus?.state !== 'deleted' + } + + if (!room.roomId) { + return true + } + + const members = Array.isArray(room.members) ? room.members : [] + + if (!members.length || members.some(member => member.remoteSource !== true)) { + return true + } + + const connections = [...new Set(members.map(member => String(member.connectionId || '')).filter(Boolean))] + + return !connections.length || connections.every(connectionId => hostedRoomInventoriedConnections.has(connectionId)) +} + +export interface HostedRoomRuntimeHooks { + renameGroupChat?: (oldName: string, newName: string, members: GroupMember[]) => Promise +} + +export interface HostedRoomProbe { + capability: HostedRoomCapability | null + capabilities: Record + eligible: boolean + route: AutonomousRoomPlan + routes: Record +} + +interface HostedRoomCreateInput { + members: Array<{ + display_name?: string + handle: string + member_id: string + profile: string + }> + name: string + roomId: string + route: HostedRoomRouteResolution +} + +interface AutonomousHostedRoomMember { + displayName?: string + handle: string + member: GroupMember + profile: string +} + +interface AutonomousHostedRoomCreateInput { + members: AutonomousHostedRoomMember[] + name: string + probe: HostedRoomProbe + roomId: string +} + +interface HostedRoomServerMember { + display_name?: unknown + handle?: unknown + member_id?: unknown + profile?: unknown + target?: unknown +} + +interface HostedRoomServerState { + authority_epoch?: unknown + authority_gateway_id?: unknown + disbanded_at?: unknown + latest_seq?: unknown + members?: unknown + name?: unknown + room_id?: unknown +} + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null +} + +async function roomControlRequestId(roomId: string, memberId: string): Promise { + const material = new TextEncoder().encode(`room-control-v1\0${roomId}\0${memberId}`) + const digest = await globalThis.crypto.subtle.digest('SHA-256', material) + + return `room-control:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')}` +} + +function activeConnectionId() { + return String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || '') +} + +async function hostedDefaultRoutes(): Promise { + if (typeof host.profileRoutes !== 'function') { + return [] + } + + const routes = await host.profileRoutes() + const byConnection = new Map() + + for (const route of Array.isArray(routes) ? routes : []) { + const profile = String(route?.targetProfile || route?.profile || '') + const connectionId = String(route?.connectionId || '') + + if (!connectionId || profile !== 'default' || byConnection.has(connectionId)) { + continue + } + + byConnection.set(connectionId, route as ProfileRoute) + } + + return [...byConnection.values()] +} + +export async function requestHostedConnection( + route: ProfileRoute, + method: string, + params: Record = {} +): Promise { + if (!route?.connectionId || typeof host.requestProfile !== 'function') { + throw new Error(botsText().group.hostRouteMissing) + } + + return host.requestProfile(route, method, params) as Promise +} + +async function withHostedRoomProbeTimeout(task: Promise, timeoutMs = 3000) { + let timer: null | ReturnType = null + + try { + return await Promise.race([ + task, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Host check timed out')), timeoutMs) + }) + ]) + } finally { + if (timer !== null) { + clearTimeout(timer) + } + } +} + +function sourceLabel(connectionId: string) { + const source = ($lastRoster.get() || []).find(row => String(row?.connectionId || '') === connectionId) + + return String(source?.connectionLabel || botsText().group.thisHost) +} + +function hostedMemberDescriptors( + room: HostedRoomServerState, + homeConnectionId: string, + existingMembers: GroupMember[], + capabilities: Record +): GroupMember[] { + return (Array.isArray(room?.members) ? room.members : []).map(raw => { + const member = (record(raw) || {}) as HostedRoomServerMember + const profile = String(member.profile || member.member_id || 'default') + const handle = String(member.handle || member.profile || 'hermes') + const target = record(member.target) + + const targetAuthority = target?.kind === 'peer' ? String(target.installation_id || target.peer_id || '') : '' + + const prior = (existingMembers || []).find( + candidate => + String(candidate?.handle || candidate?.name || '') === handle && + String(candidate?.targetProfile || candidate?.name || '') === profile + ) + + const peerConnectionId = targetAuthority + ? Object.entries(capabilities).find(([, capability]) => capability.authorityId === targetAuthority)?.[0] || '' + : '' + + const connectionId = targetAuthority ? peerConnectionId || String(prior?.connectionId || '') : homeConnectionId + + const connectionLabel = connectionId ? sourceLabel(connectionId) : String(prior?.connectionLabel || '') + + const sourceReachable = connectionId + ? capabilities[connectionId] + ? isHostedRoomContinuityEligible(capabilities[connectionId]) + : prior?.sourceReachable !== false + : false + + return { + name: profile, + handle, + title: String(member.display_name || ''), + ...(connectionId + ? { + connectionId, + connectionLabel, + route: { + connectionId, + mode: 'remote', + profile, + targetProfile: profile + } + } + : { + sourceMissing: true, + sourceReachable: false + }), + remoteSource: true, + sourceScoped: true, + sourceReachable, + targetProfile: profile + } + }) +} + +function hostedRoomContinuityMode(room: HostedRoomServerState) { + return (Array.isArray(room?.members) ? room.members : []).some(raw => record(record(raw)?.target)?.kind === 'peer') + ? ('distributed' as const) + : ('gateway' as const) +} + +async function ensureReciprocalRoomControls( + room: HostedRoomServerState, + homeRoute: ProfileRoute, + routes: Record, + capabilities: Record, + generation: number +) { + const isCurrent = () => !hostedRoomSyncDisposed && generation === hostedRoomControlGeneration + const roomId = String(room.room_id || '') + const authorityEpoch = Number(room.authority_epoch || 0) + const homeConnectionId = String(homeRoute.connectionId || '') + + if ( + !isCurrent() || + !roomId || + !Number.isSafeInteger(authorityEpoch) || + authorityEpoch < 1 || + capabilities[homeConnectionId]?.reciprocalControl !== true + ) { + return + } + + for (const raw of Array.isArray(room.members) ? room.members : []) { + const member = (record(raw) || {}) as HostedRoomServerMember + const target = record(member.target) + + if (target?.kind !== 'peer') { + continue + } + + const memberId = String(member.member_id || '') + const targetProfile = String(member.profile || member.member_id || 'default') + const targetAuthority = String(target.installation_id || target.peer_id || '') + + const peerConnectionId = Object.entries(capabilities).find( + ([, capability]) => capability.authorityId === targetAuthority + )?.[0] + + const peerRoute = peerConnectionId ? routes[peerConnectionId] : null + const key = `${roomId}:${authorityEpoch}:${memberId}:${targetAuthority}` + + if ( + !memberId || + !targetAuthority || + !peerConnectionId || + !peerRoute || + capabilities[peerConnectionId]?.reciprocalControl !== true || + hostedRoomControlEnrolled.has(key) || + Number(hostedRoomControlRetryAfter.get(key) || 0) > Date.now() + ) { + continue + } + + try { + const control = record( + await requestHostedConnection(homeRoute, 'groups.control.invite', { + room_id: roomId, + member_id: memberId, + caller_install_id: targetAuthority, + request_id: await roomControlRequestId(roomId, memberId) + }) + ) + + if (!isCurrent()) { + return + } + + if ( + !control?.control_token || + !control.home_url || + !control.authority_gateway_id || + !control.authority_epoch || + !control.room_name || + !control.member_count || + !control.expires_at + ) { + throw new Error('Group Chat control invitation is incomplete.') + } + + await requestHostedConnection(peerRoute, 'groups.control.register', { + room_id: roomId, + member_id: memberId, + authority_gateway_id: control.authority_gateway_id, + authority_epoch: control.authority_epoch, + room_name: control.room_name, + member_count: control.member_count, + profile: targetProfile, + home_url: control.home_url, + control_token: control.control_token, + expires_at: control.expires_at + }) + + if (!isCurrent()) { + return + } + hostedRoomControlEnrolled.add(key) + hostedRoomControlRetryAfter.delete(key) + } catch { + if (isCurrent()) { + hostedRoomControlRetryAfter.set(key, Date.now() + 30_000) + } + } + } +} + +function scheduleReciprocalRoomControls( + room: HostedRoomServerState, + homeRoute: ProfileRoute, + routes: Record, + capabilities: Record +) { + const key = `${String(room.room_id || '')}:${Number(room.authority_epoch || 0)}` + + if (hostedRoomControlPending.has(key)) { + return + } + + const generation = hostedRoomControlGeneration + hostedRoomControlPending.set(key, generation) + hostedRoomControlQueue = hostedRoomControlQueue + .then(async () => { + if (!hostedRoomSyncDisposed && generation === hostedRoomControlGeneration) { + await ensureReciprocalRoomControls(room, homeRoute, routes, capabilities, generation) + } + }) + .catch(() => undefined) + .finally(() => { + if (hostedRoomControlPending.get(key) === generation) { + hostedRoomControlPending.delete(key) + } + }) +} + +function markHostedConnectionUnavailable(connectionId: string, unsupported = false) { + const connectionName = sourceLabel(connectionId) + + for (const [name, room] of Object.entries($groupChats.get())) { + if (String(room?.hostedConnectionId || '') !== connectionId) { + continue + } + + updateGroupChat( + name, + current => ({ + ...current, + running: false, + hostedStatus: { + state: unsupported ? 'unsupported' : 'offline', + label: unsupported + ? botsText().group.hostUpdateNeeded(connectionName) + : botsText().group.hostedUnavailable(connectionName) + }, + continuityIssue: unsupported ? null : botsText().group.hostReconnectToContinue(connectionName) + }), + { + sync: false + } + ) + } +} + +export function hostedRoomDriverDisplayStatus( + replay: FriendlyHostedRoomStatus, + driverValue: unknown, + { stopping = false }: { stopping?: boolean } = {} +): FriendlyHostedRoomStatus { + const driver = record(driverValue) + const counts = record(driver?.counts) + + if (stopping || Number(counts?.stopping || 0) > 0) { + return { ...replay, kind: 'stopping', canStop: false } + } + + if (['failed', 'member-unavailable', 'needs-attention', 'needs-you', 'waiting'].includes(replay.kind)) { + return replay + } + + if (Number(counts?.queued || driver?.queued || 0) > 0) { + return { ...replay, kind: 'queued', canStop: true } + } + + if (driver?.working === true || replay.kind === 'working') { + return { ...replay, kind: 'working', canStop: true } + } + + return replay +} + +function hostedStatus(status: FriendlyHostedRoomStatus, connectionName: string) { + const b = botsText() + const member = status.member || b.group.aBot + + const labels: Record = { + deleted: b.group.hostedDeleted, + offline: b.group.hostedUnavailable(connectionName), + queued: b.group.hostedQueued(connectionName), + ready: b.roster.ready, + stopping: b.group.hostedStopping, + working: b.group.memberThinking(member), + 'member-unavailable': b.group.memberUnavailable(member), + 'needs-attention': b.group.memberNeedsAttention(member), + failed: b.group.memberCouldNotRespond(member), + waiting: b.group.memberRetryWhenOnline(member), + stopped: b.group.hostedStopped, + 'needs-you': b.group.waitingForAnswer + } + + return { + state: status.kind, + label: labels[status.kind] || b.roster.statusUnknown, + ...(status.canRetry === undefined + ? {} + : { + canRetry: status.canRetry + }), + ...(status.canStop === undefined + ? {} + : { + canStop: status.canStop + }) + } +} + +function replayMessages(messages: ReturnType['messages']): GroupMessage[] { + return messages.map(message => ({ + at: message.at, + from: message.from, + id: message.eventId, + eventId: message.eventId, + seq: message.seq, + text: message.text, + thread: message.thread + })) +} + +function isDisbanded(room: HostedRoomServerState) { + return room.disbanded_at !== null && room.disbanded_at !== undefined +} + +export function hostedRoomPollFingerprint(value: unknown) { + const room = record(value) + const revision = Math.max(0, Number(room?.revision || 0)) + const latestSeq = Math.max(0, Number(room?.latest_seq || 0)) + + return `${revision}:${latestSeq}` +} + +function hostedRoomCapabilityFingerprint(capability: HostedRoomCapability | undefined) { + if (!capability) { + return '' + } + + return JSON.stringify([ + capability.kind, + capability.authorityId, + capability.persistentProcess, + capability.exactPeerGrantRevoke, + capability.routeGrantFingerprint, + capability.reciprocalControl + ]) +} + +function invalidateHostedRoomsForConnection(connectionId: string) { + for (const room of Object.values($groupChats.get())) { + if ( + room.hostedConnectionId === connectionId || + (room.members || []).some( + member => String(member.route?.connectionId || member.connectionId || '') === connectionId + ) + ) { + hostedRoomPollCache.delete(String(room.roomId || '')) + } + } +} + +export function invalidateHostedRoomPoll(roomId: string) { + const id = String(roomId || '') + + hostedRoomPollCache.delete(id) + hostedRoomPollGenerations.set(id, Number(hostedRoomPollGenerations.get(id) || 0) + 1) +} + +export function shouldRefreshHostedRoom(room: GroupChat | undefined, listed: unknown) { + if (!room) { + return true + } + + const activeStates = new Set(['queued', 'sending', 'stopping', 'working']) + + const active = + room.running === true || + activeStates.has(String(room.hostedStatus?.state || '')) || + $hostedRoomOutbox.get().commands.some(command => command.roomId === room.roomId && command.status !== 'failed') + + const fingerprint = hostedRoomPollFingerprint(listed) + + return active || hostedRoomPollCache.get(String(room.roomId || '')) !== fingerprint +} + +/** Replay every hosted room only after plugin storage/ui_meta hydration has + * settled. The contiguous cursor is persisted with the room, so reconnects + * fetch only missing events and a gap never skips unseen history. */ +export async function refreshHostedRooms() { + if (hostedRoomSyncDisposed || hostedRoomSyncRunning) { + return + } + + const lifecycleGeneration = hostedRoomLifecycleGeneration + const syncStale = () => hostedRoomSyncDisposed || lifecycleGeneration !== hostedRoomLifecycleGeneration + + hostedRoomSyncRunning = true + + try { + const routes = await hostedDefaultRoutes() + const routesByConnection = Object.fromEntries(routes.map(route => [String(route.connectionId || ''), route])) + + const capabilities = { + ...$hostedRoomCapabilities.get() + } + + for (const route of routes) { + if (syncStale()) { + return + } + + const connectionId = String(route.connectionId) + let capability: HostedRoomCapability + + const cached = capabilities[connectionId] + + if (cached?.kind === 'unsupported' && Number(hostedUnsupportedUntil.get(connectionId) || 0) > Date.now()) { + capability = cached + } else { + try { + capability = classifyHostedRoomCapability(await requestHostedConnection(route, 'groups.capabilities'), { + connectionId + }) + } catch (error) { + capability = classifyHostedRoomCapability( + { + ok: false, + error + }, + { + connectionId + } + ) + } + + if (capability.kind === 'unsupported') { + hostedUnsupportedUntil.set(connectionId, Date.now() + HOSTED_ROOM_UNSUPPORTED_REPROBE_MS) + } else { + hostedUnsupportedUntil.delete(connectionId) + } + } + + if (syncStale()) { + return + } + + if (hostedRoomCapabilityFingerprint(cached) !== hostedRoomCapabilityFingerprint(capability)) { + invalidateHostedRoomsForConnection(connectionId) + } + + capabilities[connectionId] = capability + } + + if (syncStale()) { + return + } + + for (const route of routes) { + const connectionId = String(route.connectionId) + const capability = capabilities[connectionId] + + if (!isHostedRoomContinuityEligible(capability) || !capability.authorityId) { + if (capability.kind === 'unsupported') { + hostedRoomInventoriedConnections.add(connectionId) + } + + markHostedConnectionUnavailable(connectionId, capability.kind === 'unsupported') + + continue + } + + hostedAuthorityRoutes.set(capability.authorityId, route) + const listedRooms: unknown[] = [] + let listOffset = 0 + let listComplete = false + + try { + for (let page = 0; page < HOSTED_ROOM_LIST_MAX_PAGES; page += 1) { + const listed = await requestHostedConnection>(route, 'groups.list', { + include_disbanded: true, + limit: HOSTED_ROOM_LIST_PAGE_SIZE, + offset: listOffset + }) + + const rows = Array.isArray(listed?.rooms) ? listed.rooms : [] + + listedRooms.push(...rows) + + const nextOffset = Number(listed?.next_offset) + + if (!Number.isSafeInteger(nextOffset) || nextOffset <= listOffset) { + listComplete = true + + break + } + + listOffset = nextOffset + } + } catch { + if (syncStale()) { + return + } + markHostedConnectionUnavailable(connectionId) + + continue + } + + if (syncStale()) { + return + } + + const disbandedIds = new Set( + listedRooms + .map(raw => (record(raw) || {}) as HostedRoomServerState) + .filter(isDisbanded) + .map(room => String(room.room_id || '')) + .filter(Boolean) + ) + const caughtUpDisbandedIds = new Set() + + for (const listedRaw of listedRooms) { + const listedRoom = (record(listedRaw) || {}) as HostedRoomServerState + const roomId = String(listedRoom.room_id || '') + const serverName = String(listedRoom.name || '').trim() + + if (!roomId || !serverName || hostedRoomLocallyDeleted.has(roomId)) { + continue + } + + scheduleReciprocalRoomControls(listedRoom, route, routesByConnection, capabilities) + + const existingEntry = Object.entries($groupChats.get()).find( + ([, room]) => String(room?.roomId || '') === roomId + ) + const includeDisbanded = isDisbanded(listedRoom) + + // A client that already joined the room must replay terminal events + // committed while it was offline before painting the remote disband. + // Unknown disbanded rooms remain invisible on newly connected clients. + if (includeDisbanded && !existingEntry) { + continue + } + + if (!shouldRefreshHostedRoom(existingEntry?.[1], listedRoom)) { + if ( + includeDisbanded && + Math.max(0, Number(existingEntry?.[1]?.hostedSeq || 0)) >= Math.max(0, Number(listedRoom.latest_seq || 0)) + ) { + caughtUpDisbandedIds.add(roomId) + } + + continue + } + + const refreshGeneration = hostedRoomMutationGeneration(roomId) + const pollGeneration = Number(hostedRoomPollGenerations.get(roomId) || 0) + + let stateResponse: Record + + try { + stateResponse = await requestHostedConnection(route, 'groups.state', { + room_id: roomId, + ...(includeDisbanded ? { include_disbanded: true } : {}) + }) + } catch { + if (syncStale()) { + return + } + markHostedConnectionUnavailable(connectionId) + + continue + } + + if (syncStale()) { + return + } + + if (!hostedRoomMutationIsCurrent(roomId, refreshGeneration)) { + continue + } + + const serverRoom = (record(stateResponse.room) || listedRoom) as unknown as HostedRoomServerState + + let existingName = existingEntry?.[0] + let existing = existingEntry?.[1] + const taken = new Set(Object.keys($groupChats.get())) + + let localName = + existingName || + (taken.has(serverName) + ? uniqueGroupChatName(`${serverName} (${sourceLabel(connectionId)})`, taken) + : serverName) + + const renamePending = $hostedRoomOutbox + .get() + .commands.some( + command => command.kind === 'rename' && command.roomId === roomId && command.status !== 'failed' + ) + + if (existingName && existingName !== serverName && !renamePending && hostedRoomHooks.renameGroupChat) { + const occupant = $groupChats.get()[serverName] + const renameTaken = new Set(taken) + + renameTaken.delete(existingName) + + const targetName = + occupant && occupant.roomId !== roomId + ? uniqueGroupChatName(`${serverName} (${sourceLabel(connectionId)})`, renameTaken) + : serverName + + const renamed = await hostedRoomHooks.renameGroupChat( + existingName, + targetName, + Array.isArray(existing?.members) ? existing.members : [] + ) + + if (renamed) { + existingName = renamed + localName = renamed + existing = $groupChats.get()[renamed] + } + + if (syncStale()) { + return + } + + if (!hostedRoomMutationIsCurrent(roomId, refreshGeneration)) { + continue + } + } + + const replay = await replayHostedRoomPages({ + state: createHostedRoomReplayState({ + roomId, + name: serverName, + members: Array.isArray(serverRoom.members) ? (serverRoom.members as Array>) : [], + authorityId: String(serverRoom.authority_gateway_id || capability.authorityId), + authorityEpoch: Number(serverRoom.authority_epoch || 1), + connectionId, + cursor: Number(existing?.hostedSeq || 0) + }), + fetchPage: request => + requestHostedConnection(route, 'groups.log', { + room_id: roomId, + since_seq: request.sinceSeq, + limit: request.limit, + ...(includeDisbanded ? { include_disbanded: true } : {}) + }), + pageSize: capability.maxLogLimit || 100 + }) + + if (syncStale()) { + return + } + + if (!hostedRoomMutationIsCurrent(roomId, refreshGeneration)) { + continue + } + + const replayStatus = deriveFriendlyHostedRoomStatus(replay.state) + const driver = record(stateResponse.driver_status) + const reconnectRoute = (Array.isArray(driver?.peer_routes) ? driver.peer_routes : []) + .map(record) + .find(route => route?.status === 'needs_reauthorization' && String(route?.member_id || '')) + const reconnectMemberId = String(reconnectRoute?.member_id || '') + const reconnectMember = (Array.isArray(serverRoom.members) ? serverRoom.members : []) + .map(record) + .find(member => String(member?.member_id || '') === reconnectMemberId) + const reconnectName = String( + reconnectMember?.display_name || reconnectMember?.handle || reconnectMember?.profile || botsText().group.aBot + ) + const reconnectTarget = record(reconnectMember?.target) + const reconnectAuthority = String(reconnectTarget?.installation_id || reconnectTarget?.peer_id || '') + const reconnectPrior = (existing?.members || []).find( + member => + String(member.handle || member.name || '') === + String(reconnectMember?.handle || reconnectMember?.profile || '') && + String(member.targetProfile || member.name || '') === + String(reconnectMember?.profile || reconnectMember?.member_id || '') + ) + const reconnectConnectionId = + Object.entries(capabilities).find(([, candidate]) => candidate.authorityId === reconnectAuthority)?.[0] || + String(reconnectPrior?.route?.connectionId || reconnectPrior?.connectionId || '') + const reconnectCapability = reconnectConnectionId ? capabilities[reconnectConnectionId] : undefined + const reconnectCapabilityKnown = Boolean(reconnectCapability) + const reconnectSupported = Boolean( + capability.routeGrantFingerprint && + reconnectConnectionId && + reconnectCapability?.kind === 'driver-capable' && + reconnectCapability.exactPeerGrantRevoke + ) + const reconnectUpdateConnectionId = !capability.routeGrantFingerprint + ? connectionId + : reconnectCapability?.kind === 'unsupported' || + (reconnectCapability?.kind === 'driver-capable' && !reconnectCapability.exactPeerGrantRevoke) + ? reconnectConnectionId + : '' + + const stopping = $hostedRoomOutbox + .get() + .commands.some( + command => + command.roomId === roomId && ['disband', 'stop'].includes(command.kind) && command.status !== 'failed' + ) + + const friendly = reconnectMemberId + ? { + ...replayStatus, + kind: 'needs-attention' as const, + member: reconnectName, + canRetry: false, + canStop: false + } + : hostedRoomDriverDisplayStatus(replayStatus, driver, { stopping }) + const running = ['queued', 'stopping', 'working'].includes(friendly.kind) + + const retryAction = (Array.isArray(driver?.pending_actions) ? driver.pending_actions : []) + .map(record) + .find(action => action?.kind === 'retry' && String(action?.task_id || '')) + + updateGroupChat( + localName, + current => { + const authoritative = applyHostedRoomAuthority(current, serverRoom as Record) + + return { + ...authoritative, + roomId, + members: hostedMemberDescriptors(serverRoom, connectionId, current.members || [], capabilities), + log: mergeGroupChatSyncEntries(current.log || [], replayMessages(replay.state.messages)), + hostedConnectionId: connectionId, + hostedSeq: replay.state.cursor, + hostedStatus: { + ...hostedStatus(friendly, sourceLabel(connectionId)), + ...(retryAction ? { taskId: String(retryAction.task_id) } : {}), + ...(reconnectMemberId && reconnectSupported + ? { + canReconnect: true, + reconnectMemberId + } + : {}), + ...(!replay.complete && !reconnectMemberId ? { canRetry: true } : {}) + }, + continuityMode: hostedRoomContinuityMode(serverRoom), + continuityIssue: reconnectMemberId + ? !reconnectCapabilityKnown || reconnectCapability?.kind === 'transient-failure' + ? botsText().group.hostedSyncing + : reconnectSupported + ? botsText().group.memberReconnectToContinue(reconnectName) + : botsText().group.hostUpdateNeeded( + reconnectUpdateConnectionId ? sourceLabel(reconnectUpdateConnectionId) : reconnectName + ) + : replay.complete + ? null + : botsText().group.hostedSyncing, + running + } + }, + { + sync: false + } + ) + + if ( + replay.complete && + (!reconnectMemberId || Boolean(reconnectUpdateConnectionId)) && + Number(hostedRoomPollGenerations.get(roomId) || 0) === pollGeneration + ) { + hostedRoomPollCache.set(roomId, hostedRoomPollFingerprint(listedRoom)) + + if (includeDisbanded) { + caughtUpDisbandedIds.add(roomId) + } + } else { + hostedRoomPollCache.delete(roomId) + } + } + + // Keep the local shell long enough to explain a disband observed on + // another client. Silently deleting only the room atom would strand an + // open workspace and leave membership metadata half-cleaned. The normal + // local disband action performs the complete cross-module cleanup. + if (disbandedIds.size) { + for (const [name, room] of Object.entries($groupChats.get())) { + if ( + room.roomId && + disbandedIds.has(room.roomId) && + caughtUpDisbandedIds.has(room.roomId) && + room.hostedConnectionId === connectionId + ) { + updateGroupChat( + name, + current => ({ + ...current, + running: false, + hostedStatus: { + state: 'deleted', + label: botsText().group.hostedDeleted + }, + continuityIssue: botsText().group.hostedDeleteLocally + }), + { + sync: false + } + ) + } + } + } + + if (listComplete) { + hostedRoomInventoriedConnections.add(connectionId) + const listedIds = new Set(listedRooms.map(raw => String(record(raw)?.room_id || '')).filter(Boolean)) + + for (const [name, room] of Object.entries($groupChats.get())) { + const roomId = String(room?.roomId || '') + + if (!roomId || room.hostedConnectionId !== connectionId || listedIds.has(roomId)) { + continue + } + + try { + await requestHostedConnection(route, 'groups.state', { + room_id: roomId, + include_disbanded: true + }) + + continue + } catch (error) { + if (syncStale()) { + return + } + const message = String(record(error)?.message || record(record(error)?.error)?.message || error || '') + + if (!/history expired|permanently retired|hosted room not found/i.test(message)) { + continue + } + } + + hostedRoomPollCache.delete(roomId) + updateGroupChat( + name, + current => ({ + ...current, + running: false, + hostedStatus: { + state: 'deleted', + label: botsText().group.hostedDeleted + }, + continuityIssue: botsText().group.hostedDeleteLocally + }), + { sync: false } + ) + } + } + } + + if (!syncStale()) { + $hostedRoomCapabilities.set(capabilities) + } + } finally { + hostedRoomSyncRunning = false + } +} + +function scheduleHostedRoomSync(delay = HOSTED_ROOM_SYNC_INTERVAL_MS) { + if (hostedRoomSyncDisposed || typeof setTimeout !== 'function') { + return + } + + if (hostedRoomSyncTimer) { + clearTimeout(hostedRoomSyncTimer) + } + + hostedRoomSyncTimer = setTimeout(() => { + hostedRoomSyncTimer = null + void dispatchHostedRoomCleanup() + .catch(() => undefined) + .then(() => refreshHostedRooms()) + .catch(() => undefined) + .then(() => dispatchHostedRoomOutbox()) + .catch(() => undefined) + .then(() => scheduleHostedRoomSync()) + }, delay) + + const timer = hostedRoomSyncTimer as ReturnType & { unref?: () => void } + timer?.unref?.() +} + +async function transitionHostedRoomOutbox(action: Parameters[1]) { + if (typeof hostedRoomStorage?.set !== 'function') { + throw new Error(botsText().group.desktopStorageUnavailable) + } + + const previous = $hostedRoomOutbox.get() + const next = reduceHostedRoomOutbox(previous, action) + + $hostedRoomOutbox.set(next) + + try { + await hostedRoomStorage.set(HOSTED_ROOM_OUTBOX_KEY, next) + } catch (error) { + $hostedRoomOutbox.set(previous) + throw error + } + + return next +} + +const TERMINAL_HOSTED_ROOM_COMMAND_CODES = new Set([4110, 4111, 4113, 4117]) + +function terminalCommandFailure(error: unknown) { + const candidate = record(error) + const nested = record(candidate?.error) + const code = Number(candidate?.code ?? nested?.code) + + return Number.isInteger(code) && TERMINAL_HOSTED_ROOM_COMMAND_CODES.has(code) +} + +export async function dispatchHostedRoomOutbox() { + if (hostedOutboxDispatching || hostedRoomSyncDisposed) { + return + } + + hostedOutboxDispatching = true + + try { + let state = $hostedRoomOutbox.get() + + for (const command of state.commands.filter(entry => entry.status === 'pending')) { + const route = (await hostedDefaultRoutes()).find(candidate => candidate.connectionId === command.connectionId) + + if (hostedRoomSyncDisposed) { + return + } + + if (!route) { + continue + } + + state = await transitionHostedRoomOutbox({ + type: 'dispatch', + commandId: command.commandId + }) + + const method: Record = { + create: 'groups.create', + retry: 'groups.retry', + rename: 'groups.rename', + send: 'groups.send', + stop: 'groups.stop', + disband: 'groups.disband' + } + + const params = + command.kind === 'send' + ? { + room_id: command.roomId, + event_id: command.commandId, + payload: command.payload + } + : command.kind === 'rename' + ? { + room_id: command.roomId, + event_id: command.commandId, + name: command.payload.name + } + : command.kind === 'retry' + ? { + room_id: command.roomId, + task_id: command.payload.task_id, + command_id: command.commandId + } + : command.kind === 'stop' || command.kind === 'disband' + ? { + room_id: command.roomId, + cancel_id: command.commandId + } + : command.payload + + try { + await requestHostedConnection(route, method[command.kind], params) + + // Keep the persisted in-flight command untouched when the window is + // disposed mid-request. Rehydration returns it to pending with the + // same idempotency key, covering an unknown server outcome safely. + if (hostedRoomSyncDisposed) { + return + } + + state = await transitionHostedRoomOutbox({ + type: 'acknowledge', + commandId: command.commandId + }) + } catch (error) { + state = await transitionHostedRoomOutbox( + terminalCommandFailure(error) + ? { + type: 'terminal-failure', + commandId: command.commandId, + failureCode: String(record(error)?.code || 'command-rejected') + } + : { + type: 'transient-failure', + commandId: command.commandId + } + ) + } + } + } finally { + hostedOutboxDispatching = false + } +} + +async function enqueueHostedRoomCommand(command: Partial) { + await transitionHostedRoomOutbox({ + type: 'enqueue', + command + }) + await dispatchHostedRoomOutbox() + + const pending = $hostedRoomOutbox.get().commands.find(entry => entry.commandId === command.commandId) + + if (pending?.status === 'failed') { + throw new Error(botsText().group.hostRejectedCommand) + } + + scheduleHostedRoomSync(0) + + return !pending +} + +async function hostedRouteForRoom(room: GroupChat) { + const connectionId = String(room?.hostedConnectionId || '') + const routes = await hostedDefaultRoutes() + + if (connectionId) { + const exact = routes.find(candidate => candidate.connectionId === connectionId) + + if (exact) { + return exact + } + } + + return hostedAuthorityRoutes.get(groupChatHostedGateway(room)) || null +} + +export async function probeHostedRoomMembers(members: GroupMember[]): Promise { + const routes = Object.fromEntries( + (await hostedDefaultRoutes()).map(route => [String(route.connectionId || ''), route]) + ) + + const connectionIds = [ + ...new Set( + (Array.isArray(members) ? members : []) + .map(member => String(member?.route?.connectionId || member?.connectionId || activeConnectionId() || '')) + .filter(Boolean) + ) + ] + + const capabilities: Record = {} + const now = Date.now() + + for (const connectionId of connectionIds) { + const cached = $hostedRoomCapabilities.get()[connectionId] + + if (cached?.kind === 'unsupported' && Number(hostedUnsupportedUntil.get(connectionId) || 0) > now) { + capabilities[connectionId] = cached + + continue + } + + const route = routes[connectionId] + let capability: HostedRoomCapability + + try { + capability = classifyHostedRoomCapability( + route + ? await withHostedRoomProbeTimeout(requestHostedConnection(route, 'groups.capabilities')) + : { ok: false, error: new Error('Gateway route unavailable') }, + { connectionId } + ) + } catch (error) { + capability = classifyHostedRoomCapability({ ok: false, error }, { connectionId }) + } + + capabilities[connectionId] = capability + + if (capability.kind === 'unsupported') { + hostedUnsupportedUntil.set(connectionId, now + HOSTED_ROOM_UNSUPPORTED_REPROBE_MS) + } else { + hostedUnsupportedUntil.delete(connectionId) + } + + if (capability.authorityId && isHostedRoomContinuityEligible(capability) && route) { + hostedAuthorityRoutes.set(capability.authorityId, route) + } + } + + $hostedRoomCapabilities.set({ ...$hostedRoomCapabilities.get(), ...capabilities }) + + const route = resolveAutonomousRoomPlan(members, { + activeConnectionId: activeConnectionId(), + capabilities + }) + + const capability = route.connectionId ? capabilities[route.connectionId] || null : null + + return { + route, + routes, + capabilities, + capability, + eligible: route.kind !== 'unsupported' && isHostedRoomContinuityEligible(capability) + } +} + +export async function createHostedGroupChat({ route, roomId, name, members }: HostedRoomCreateInput): Promise<{ + authorityEpoch: number + authorityId: string + connectionId: string +}> { + if ((route.kind !== 'single-gateway' && route.kind !== 'multi-gateway') || !route.connectionId) { + throw new Error(botsText().group.botsNeedOneHost) + } + + const profileRoute = (await hostedDefaultRoutes()).find(candidate => candidate.connectionId === route.connectionId) + + if (!profileRoute) { + throw new Error(botsText().group.hostRouteMissing) + } + + let room: Record | null = null + + try { + const result = await requestHostedConnection>(profileRoute, 'groups.create', { + room_id: roomId, + name, + members + }) + + room = record(result.room) + } catch (createError) { + // A dropped response has an unknown outcome. Verify the idempotent room id + // before falling back to Desktop, or both drivers could start the first + // user turn. A true create failure has no state and safely falls through. + try { + const state = await requestHostedConnection>(profileRoute, 'groups.state', { + room_id: roomId + }) + + room = record(state.room) + } catch { + throw createError + } + } + + const authorityId = String(room?.authority_gateway_id || '') + + if (!authorityId) { + throw new Error(botsText().group.hostRejectedCommand) + } + + hostedAuthorityRoutes.set(authorityId, profileRoute) + + return { + authorityId, + authorityEpoch: Math.max(1, Number(room?.authority_epoch || 1)), + connectionId: route.connectionId + } +} + +export async function createAutonomousHostedGroupChat({ + probe, + roomId, + name, + members +}: AutonomousHostedRoomCreateInput) { + const plan = probe.route + const homeConnectionId = String(plan.homeConnectionId || '') + const homeRoute = probe.routes[homeConnectionId] + const homeCapability = probe.capabilities[homeConnectionId] + + if (!probe.eligible || !homeConnectionId || !homeRoute || !homeCapability?.authorityId) { + throw new Error('This Group Chat cannot continue without Desktop yet.') + } + + const hostedMembers: Array> = [] + const peerRegistrations: Array> = [] + + try { + await addHostedRoomCleanup({ + operationId: `${roomId}:home-disband`, + setupId: roomId, + kind: 'home-disband', + connectionId: homeConnectionId, + roomId, + cancelId: `rollback-${roomId}` + }) + + for (const [index, item] of members.entries()) { + const connectionId = String(item.member.route?.connectionId || item.member.connectionId || '') + const profile = String(item.member.targetProfile || item.profile || item.member.name || 'default') + const memberId = `member-${index + 1}-${profile}`.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 128) + + const descriptor: Record = { + member_id: memberId, + profile, + handle: item.handle, + ...(item.displayName + ? { + display_name: item.displayName + } + : {}) + } + + if (connectionId === homeConnectionId) { + hostedMembers.push(descriptor) + + continue + } + + const invitation = record( + await requestForBot(item.member, 'groups.peer.invite', { + room_id: roomId, + home_install_id: homeCapability.authorityId, + authority_gateway_id: homeCapability.authorityId, + authority_epoch: 1, + member_id: memberId, + profile + }) + ) + + const catalog = record(invitation?.catalog) + const invitedProfile = String(invitation?.target_profile || profile || '') + + const scopedTargetUrl = profileScopedRoomLinkEndpoint( + probe.capabilities[connectionId]?.roomLink?.endpoint, + invitation?.target_profile + ) + + if (invitation?.grant && invitedProfile) { + await addHostedRoomCleanup({ + operationId: `${roomId}:peer-revoke:${memberId}`, + setupId: roomId, + kind: 'peer-revoke', + connectionId, + profile: invitedProfile, + grant: String(invitation.grant) + }) + } + + if ( + !scopedTargetUrl || + !invitation?.grant || + !catalog?.installation_id || + !catalog.catalog_digest || + !invitation.target_profile + ) { + throw new Error('One selected Bot could not prepare this Group Chat.') + } + + hostedMembers.push({ + ...descriptor, + profile: invitation.target_profile, + target: { + kind: 'peer', + peer_id: catalog.installation_id, + installation_id: catalog.installation_id, + profile: invitation.target_profile, + capability_digest: catalog.catalog_digest + } + }) + peerRegistrations.push({ + member: item.member, + connection_id: connectionId, + caller_install_id: catalog.installation_id, + room_id: roomId, + member_id: memberId, + target_url: scopedTargetUrl, + target_profile: invitation.target_profile, + grant: invitation.grant, + catalog + }) + } + + const created = await createHostedGroupChat({ + route: plan, + roomId, + name, + members: hostedMembers as HostedRoomCreateInput['members'] + }) + + for (const registration of peerRegistrations) { + const member = registration.member as GroupMember + const homeControl = probe.capabilities[homeConnectionId]?.reciprocalControl === true + const peerControl = probe.capabilities[String(registration.connection_id || '')]?.reciprocalControl === true + + if (homeControl && peerControl) { + const control = record( + await requestHostedConnection(homeRoute, 'groups.control.invite', { + room_id: roomId, + member_id: registration.member_id, + caller_install_id: registration.caller_install_id, + request_id: await roomControlRequestId(roomId, String(registration.member_id)) + }) + ) + + if ( + !control?.control_token || + !control.home_url || + !control.authority_gateway_id || + !control.authority_epoch || + !control.room_name || + !control.member_count || + !control.expires_at + ) { + throw new Error('One selected Bot could not prepare remote Group Chat control.') + } + + await requestForBot(member, 'groups.control.register', { + room_id: roomId, + member_id: registration.member_id, + authority_gateway_id: control.authority_gateway_id, + authority_epoch: control.authority_epoch, + room_name: control.room_name, + member_count: control.member_count, + profile: registration.target_profile, + home_url: control.home_url, + control_token: control.control_token, + expires_at: control.expires_at + }) + } + + await requestHostedConnection(homeRoute, 'groups.peer.register', { + room_id: registration.room_id, + member_id: registration.member_id, + target_url: registration.target_url, + target_profile: registration.target_profile, + grant: registration.grant, + catalog: registration.catalog + }) + } + + await releaseHostedRoomCleanup(roomId) + + return { + ...created, + continuityMode: plan.kind === 'multi-gateway' ? ('distributed' as const) : ('gateway' as const) + } + } catch (error) { + await Promise.allSettled( + peerRegistrations.map(registration => + requestForBot(registration.member as GroupMember, 'groups.control.revoke', { + room_id: roomId, + member_id: registration.member_id + }) + ) + ) + await armHostedRoomCleanup(roomId).catch(() => undefined) + await dispatchHostedRoomCleanup().catch(() => undefined) + + if (hostedRoomCleanupPending(roomId)) { + throw Object.assign( + new Error('Some selected Bots could not finish cleanup. Reconnect them before trying again.', { + cause: error + }), + { + fallbackSafe: false + } + ) + } + + throw error + } +} + +export async function sendHostedGroupChat(group: string, message: GroupMessage, thread: string) { + const room = $groupChats.get()[group] + + if (!room?.roomId || !groupChatHostedGateway(room)) { + throw new Error(botsText().group.hostRouteMissing) + } + + const route = await hostedRouteForRoom(room) + const connectionId = String(route?.connectionId || room.hostedConnectionId || '') + + if (!connectionId) { + throw new Error(botsText().group.hostRouteMissing) + } + + return enqueueHostedRoomCommand({ + commandId: String(message.id || ''), + kind: 'send', + roomId: room.roomId, + authorityId: groupChatHostedGateway(room), + connectionId, + payload: { + text: message.text || '', + thread_id: thread + } + }) +} + +export async function stopHostedGroupChat(group: string) { + const room = $groupChats.get()[group] + + if (!room?.roomId || !groupChatHostedGateway(room)) { + return false + } + + const route = await hostedRouteForRoom(room) + const connectionId = String(route?.connectionId || room.hostedConnectionId || '') + + if (!connectionId) { + throw new Error(botsText().group.hostRouteMissing) + } + + return enqueueHostedRoomCommand({ + commandId: crypto.randomUUID(), + kind: 'stop', + roomId: room.roomId, + authorityId: groupChatHostedGateway(room), + connectionId, + payload: {} + }) +} + +export async function retryHostedGroupChat(group: string, taskId: string) { + const room = $groupChats.get()[group] + + if (!room?.roomId || !groupChatHostedGateway(room) || !String(taskId || '').trim()) { + return false + } + + const route = await hostedRouteForRoom(room) + const connectionId = String(route?.connectionId || room.hostedConnectionId || '') + + if (!connectionId) { + throw new Error(botsText().group.hostRouteMissing) + } + + return enqueueHostedRoomCommand({ + commandId: crypto.randomUUID(), + kind: 'retry', + roomId: room.roomId, + authorityId: groupChatHostedGateway(room), + connectionId, + payload: { task_id: String(taskId).trim() } + }) +} + +/** Resume bounded history replay without retrying any Bot work. */ +export async function retryHostedRoomReplay(group: string) { + const room = $groupChats.get()[group] + const roomId = String(room?.roomId || '') + + if (!roomId || !groupChatHostedGateway(room)) { + return false + } + + hostedRoomPollCache.delete(roomId) + await refreshHostedRooms() + scheduleHostedRoomSync(0) + + return true +} + +export async function renameHostedGroupChat(group: string, name: string) { + const room = $groupChats.get()[group] + + if (!room?.roomId || !groupChatHostedGateway(room)) { + return true + } + + // A refresh may already be replaying the pre-rename server snapshot. Advance + // the room fence before the request so that stale replay cannot restore the + // old map key after the local rename completes or is queued for retry. + beginHostedRoomMutation(room.roomId) + + const route = await hostedRouteForRoom(room) + const connectionId = String(route?.connectionId || room.hostedConnectionId || '') + + if (!connectionId) { + throw new Error(botsText().group.hostRouteMissing) + } + + return enqueueHostedRoomCommand({ + commandId: crypto.randomUUID(), + kind: 'rename', + roomId: room.roomId, + authorityId: groupChatHostedGateway(room), + connectionId, + payload: { + name + } + }) +} + +export async function disbandHostedGroupChat(group: string) { + const room = $groupChats.get()[group] + + if (!room?.roomId || !groupChatHostedGateway(room)) { + return false + } + + const route = await hostedRouteForRoom(room) + + if (!route) { + throw new Error( + botsText().group.hostedReconnectToDelete( + sourceLabel(String(room.hostedConnectionId || '')) || botsText().group.thisHost + ) + ) + } + + return enqueueHostedRoomCommand({ + commandId: crypto.randomUUID(), + kind: 'disband', + roomId: room.roomId, + authorityId: groupChatHostedGateway(room), + connectionId: route.connectionId, + payload: {} + }) +} + +export async function startHostedRoomRuntime(storage: PluginContext['storage'], hooks: HostedRoomRuntimeHooks = {}) { + const lifecycleGeneration = ++hostedRoomLifecycleGeneration + hostedRoomStorage = storage + hostedRoomHooks = hooks + hostedRoomSyncDisposed = false + hostedRoomMutationGenerations.clear() + hostedRoomLocallyDeleted.clear() + hostedRoomInventoriedConnections.clear() + hostedRoomControlGeneration += 1 + hostedRoomControlEnrolled.clear() + hostedRoomControlPending.clear() + hostedRoomControlRetryAfter.clear() + hostedRoomControlQueue = Promise.resolve() + let persisted: unknown = null + + try { + persisted = await storage?.get?.(HOSTED_ROOM_OUTBOX_KEY, null) + } catch { + /* an empty outbox is the safe fallback */ + } + + if (hostedRoomSyncDisposed || lifecycleGeneration !== hostedRoomLifecycleGeneration) { + return + } + + try { + $hostedRoomOutbox.set(createHostedRoomOutbox(persisted)) + } catch { + $hostedRoomOutbox.set(createHostedRoomOutbox()) + } + + await startHostedRoomCleanup(storage) + if (hostedRoomSyncDisposed || lifecycleGeneration !== hostedRoomLifecycleGeneration) { + return + } + await refreshHostedRooms().catch(() => undefined) + await dispatchHostedRoomOutbox().catch(() => undefined) + scheduleHostedRoomSync() +} + +export function stopHostedRoomRuntime() { + hostedRoomLifecycleGeneration += 1 + hostedRoomSyncDisposed = true + stopHostedRoomCleanup() + hostedRoomControlGeneration += 1 + hostedRoomStorage = null + hostedRoomHooks = {} + hostedAuthorityRoutes.clear() + hostedRoomPollCache.clear() + hostedRoomPollGenerations.clear() + hostedRoomMutationGenerations.clear() + hostedRoomLocallyDeleted.clear() + hostedRoomInventoriedConnections.clear() + hostedRoomControlEnrolled.clear() + hostedRoomControlPending.clear() + hostedRoomControlRetryAfter.clear() + hostedRoomControlQueue = Promise.resolve() + hostedUnsupportedUntil.clear() + + if (hostedRoomSyncTimer) { + clearTimeout(hostedRoomSyncTimer) + } + + hostedRoomSyncTimer = null +} + +/** Test-only lifecycle reset through the same public stop door. */ +export function resetHostedRoomRuntimeForTests() { + stopHostedRoomRuntime() + hostedRoomSyncRunning = false + hostedOutboxDispatching = false + resetHostedRoomCleanupForTests() + $hostedRoomCapabilities.set({}) + $hostedRoomOutbox.set(createHostedRoomOutbox()) +} diff --git a/apps/desktop/src/plugins/hermes-bots/i18n.test.ts b/apps/desktop/src/plugins/hermes-bots/i18n.test.ts index d2d162d847776..8a1891785cc09 100644 --- a/apps/desktop/src/plugins/hermes-bots/i18n.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/i18n.test.ts @@ -67,4 +67,43 @@ describe('BOTS_LOCALES', () => { expect(reasonFn(sentinel)).toContain(sentinel) } }) + + it('keeps automatic continuity copy concise and free of gateway jargon', () => { + const byPath = Object.fromEntries(leafEntries(en!)) + const copy = (path: string) => byPath[`group.${path}`] as (value: string) => string + const text = (path: string) => byPath[`group.${path}`] as string + + const samples = [ + copy('hostedFallbackToDesktop')('Studio'), + copy('hostedQueued')('Studio'), + copy('hostedQueuedHint')('Studio'), + copy('hostedSendFailed')('Studio'), + copy('hostedRenameQueued')('Studio'), + copy('hostedRenameFailed')('Studio'), + copy('hostUpdateNeeded')('Studio'), + copy('hostReconnectToContinue')('Studio'), + copy('hostedReconnectToStop')('Studio'), + copy('hostedReconnectToDelete')('Studio'), + text('hostedSending'), + text('hostedWorking'), + text('hostedNeedsAttention'), + text('hostedStopping'), + text('hostedStopped'), + text('hostedDeleted'), + text('hostedDeleteLocally'), + text('hostedMembersFixed'), + text('hostRouteMissing'), + text('hostedSyncing'), + text('botsNeedOneHost'), + text('desktopStorageUnavailable'), + text('hostRejectedCommand') + ] + + expect(byPath).not.toHaveProperty('group.keepRunningTitle') + + for (const sample of samples) { + expect(sample).not.toMatch(/gateway/i) + expect(sample.length).toBeLessThanOrEqual(110) + } + }) }) diff --git a/apps/desktop/src/plugins/hermes-bots/i18n.ts b/apps/desktop/src/plugins/hermes-bots/i18n.ts index 3c8e74a7f70a4..4904cea0077f5 100644 --- a/apps/desktop/src/plugins/hermes-bots/i18n.ts +++ b/apps/desktop/src/plugins/hermes-bots/i18n.ts @@ -90,6 +90,7 @@ type BotsMessages = { duplicateFailed: string deleteTitle: string removeFromAllGroups: string + removeFromOtherGroups: string createFirstHint: string createFailed: string advanced: string @@ -130,6 +131,8 @@ type BotsMessages = { /** Group chats: the room, its composer, threads and activity feed. */ group: { newTitle: string + newDesc: string + noBots: string manageDesc: string manageTitle: string settingsTitle: string @@ -161,6 +164,55 @@ type BotsMessages = { holdReleaseHint: string needsYourInput: string pictureGenerationFailed: string + createAction: (count: number) => string + created: (name: string, count: number) => string + createFailed: string + creating: string + pickAtLeastTwo: string + thisHost: string + hostedFallbackToDesktop: (host: string) => string + hostedAttachmentsUnavailable: string + hostedSending: string + hostedWorking: string + hostedQueued: (host: string) => string + hostedQueuedHint: (host: string) => string + hostedNeedsAttention: string + hostedSendFailed: (host: string) => string + hostedStopping: string + hostedStopped: string + hostedStopQueued: (host: string) => string + hostedStopQueuedHint: (host: string) => string + hostedUnavailable: (host: string) => string + hostedReconnectToStop: (host: string) => string + hostedDeleted: string + hostedDeleteLocally: string + hostedMembersFixed: string + hostedRenameQueued: (host: string) => string + hostedRenameFailed: (host: string) => string + hostRouteMissing: string + hostUpdateNeeded: (host: string) => string + hostReconnectToContinue: (host: string) => string + hostedReconnectToDelete: (host: string) => string + hostedSyncing: string + continuityOnTitle: string + continuityOnDesc: string + continuityDesktopTitle: string + continuityDesktopDesc: string + retryTitle: string + retryDesc: string + retryAction: string + reconnectAction: string + reconnectingAction: string + reconnectFailed: string + botsNeedOneHost: string + aBot: string + memberUnavailable: (member: string) => string + memberNeedsAttention: (member: string) => string + memberReconnectToContinue: (member: string) => string + memberCouldNotRespond: (member: string) => string + memberRetryWhenOnline: (member: string) => string + desktopStorageUnavailable: string + hostRejectedCommand: string nameTaken: (name: string) => string memberCount: (count: number) => string settingsHint: (group: string) => string @@ -296,6 +348,7 @@ const en: BotsMessages = { duplicateFailed: 'Duplicate failed', deleteTitle: 'Delete bot and profile?', removeFromAllGroups: 'Remove from all groups', + removeFromOtherGroups: 'Leave other groups', createFirstHint: 'Open the Bots pane and hit “New Bot”.', createFailed: 'Could not create the profile yet', advanced: 'Advanced', @@ -331,7 +384,9 @@ const en: BotsMessages = { }, group: { newTitle: 'New group chat', - manageDesc: 'A bot can join multiple group chats. Memberships sync to every machine.', + newDesc: 'Choose 2–6 Bots.', + noBots: 'No bots yet. Create a bot first.', + manageDesc: 'A Bot can join more than one Group Chat.', manageTitle: 'Manage groups', settingsTitle: 'Group settings', settingsDesc: 'Rename the group or set a room picture. Members and history are kept.', @@ -362,6 +417,56 @@ const en: BotsMessages = { holdReleaseHint: 'Mention a paused bot or send @all resume to release them.', needsYourInput: 'A bot in this group chat needs your input', pictureGenerationFailed: 'Group picture generation failed', + createAction: count => `Create Group${count ? ` (${count})` : ''}`, + created: (name, count) => `“${name}” created with ${count} bots`, + createFailed: 'Could not create the Group Chat. Try again.', + creating: 'Creating…', + pickAtLeastTwo: 'Pick at least 2 bots', + thisHost: 'this device', + hostedFallbackToDesktop: host => + `${host} can't keep this Group Chat running yet. Keep Desktop open.`, + hostedAttachmentsUnavailable: 'Attachments need Desktop mode for now.', + hostedSending: 'Sending…', + hostedWorking: 'Working', + hostedQueued: host => `Waiting for ${host}`, + hostedQueuedHint: host => `Saved. It will send when ${host} is online.`, + hostedNeedsAttention: 'Needs attention', + hostedSendFailed: host => `Not sent. Reconnect ${host} and retry.`, + hostedStopping: 'Stopping…', + hostedStopped: 'Stopped', + hostedStopQueued: host => `Stop requested. It will stop when ${host} is online.`, + hostedStopQueuedHint: host => `It will stop when ${host} is online.`, + hostedUnavailable: host => `${host} is offline`, + hostedReconnectToStop: host => `Reconnect ${host} to stop this Group Chat.`, + hostedDeleted: 'This Group Chat was deleted.', + hostedDeleteLocally: 'Delete it here to remove its local membership and history.', + hostedMembersFixed: 'Members cannot change while this Group Chat keeps running without Desktop.', + hostedRenameQueued: host => `Rename saved. It will sync when ${host} is online.`, + hostedRenameFailed: host => `Could not rename. Reconnect ${host} and retry.`, + hostRouteMissing: 'This Group Chat connection is unavailable.', + hostUpdateNeeded: host => `Update ${host} to keep this Group Chat running.`, + hostReconnectToContinue: host => `Reconnect ${host} to continue.`, + hostedReconnectToDelete: host => `Reconnect ${host} to delete this Group Chat.`, + hostedSyncing: 'Syncing recent activity…', + continuityOnTitle: 'Works without Desktop', + continuityOnDesc: 'Bots can continue while Desktop is closed.', + continuityDesktopTitle: 'Keep Desktop open', + continuityDesktopDesc: 'Bots pause when Desktop closes.', + retryTitle: 'Retry uncertain work?', + retryDesc: 'The earlier attempt may have finished. Retrying could repeat actions.', + retryAction: 'Retry', + reconnectAction: 'Reconnect', + reconnectingAction: 'Connecting…', + reconnectFailed: 'Could not reconnect this Bot. Check its gateway and try again.', + botsNeedOneHost: 'The selected Bots cannot continue when Desktop is closed.', + aBot: 'A bot', + memberUnavailable: member => `${member} is unavailable.`, + memberNeedsAttention: member => `${member} needs your attention.`, + memberReconnectToContinue: member => `Reconnect ${member} to continue this Group Chat.`, + memberCouldNotRespond: member => `${member} could not respond.`, + memberRetryWhenOnline: member => `${member} will retry when online.`, + desktopStorageUnavailable: 'Desktop could not save this action. Try again.', + hostRejectedCommand: 'The connected device rejected this action.', nameTaken: name => `A group named “${name}” already exists.`, memberCount: count => `${count} bots`, settingsHint: group => `Group settings — rename ${group} or set a room picture`, @@ -490,6 +595,7 @@ const ja: BotsMessages = { duplicateFailed: '複製に失敗しました', deleteTitle: 'ボットとプロファイルを削除しますか?', removeFromAllGroups: 'すべてのグループから外す', + removeFromOtherGroups: 'ほかのグループから外す', createFirstHint: 'ボットパネルを開いて「新しいボット」を押してください。', createFailed: 'プロファイルをまだ作成できませんでした', advanced: '詳細設定', @@ -525,7 +631,9 @@ const ja: BotsMessages = { }, group: { newTitle: '新しいグループチャット', - manageDesc: 'ボットは複数のグループチャットに参加できます。メンバーシップはすべてのマシンに同期されます。', + newDesc: '2〜6体のボットを選択してください。', + noBots: 'ボットがまだありません。先にボットを作成してください。', + manageDesc: 'ボットは複数のグループチャットに参加できます。', manageTitle: 'グループを管理', settingsTitle: 'グループ設定', settingsDesc: 'グループ名の変更や部屋の画像の設定ができます。メンバーと履歴は保持されます。', @@ -556,6 +664,56 @@ const ja: BotsMessages = { holdReleaseHint: '一時停止中のボットにメンションするか、@all resume を送信して再開します。', needsYourInput: 'このグループチャットのボットが入力を待っています', pictureGenerationFailed: 'グループ画像の生成に失敗しました', + createAction: count => `グループを作成${count ? ` (${count})` : ''}`, + created: (name, count) => `「${name}」を${count}体のボットで作成しました`, + createFailed: 'グループチャットを作成できませんでした。もう一度お試しください。', + creating: '作成中…', + pickAtLeastTwo: '2体以上のボットを選択してください', + thisHost: 'このデバイス', + hostedFallbackToDesktop: host => + `${host} ではまだこのグループチャットを継続できません。Desktopを開いたままにしてください。`, + hostedAttachmentsUnavailable: '現在、添付ファイルにはDesktopモードが必要です。', + hostedSending: '送信中…', + hostedWorking: '作業中', + hostedQueued: host => `${host} を待っています`, + hostedQueuedHint: host => `保存しました。${host} がオンラインになると送信されます。`, + hostedNeedsAttention: '確認が必要です', + hostedSendFailed: host => `送信できませんでした。${host} を再接続して再試行してください。`, + hostedStopping: '停止中…', + hostedStopped: '停止しました', + hostedStopQueued: host => `${host} への停止を保存しました`, + hostedStopQueuedHint: host => `${host} がオンラインになると停止します。`, + hostedUnavailable: host => `${host} はオフラインです`, + hostedReconnectToStop: host => `このグループチャットを停止するには ${host} を再接続してください。`, + hostedDeleted: 'このグループチャットは削除されました。', + hostedDeleteLocally: 'ローカルのメンバーシップと履歴を削除するには、ここで削除してください。', + hostedMembersFixed: 'Desktopなしで実行中のグループチャットではメンバーを変更できません。', + hostedRenameQueued: host => `名前変更を保存しました。${host} がオンラインになると同期されます。`, + hostedRenameFailed: host => `名前を変更できませんでした。${host} を再接続して再試行してください。`, + hostRouteMissing: 'このグループチャットの接続を利用できません。', + hostUpdateNeeded: host => `継続実行するには ${host} を更新してください。`, + hostReconnectToContinue: host => `続行するには ${host} を再接続してください。`, + hostedReconnectToDelete: host => `このグループチャットを削除するには ${host} を再接続してください。`, + hostedSyncing: '最近のアクティビティを同期中…', + continuityOnTitle: 'Desktopを閉じても大丈夫です', + continuityOnDesc: 'このグループチャットのボットは作業を続けます。', + continuityDesktopTitle: 'Desktopを開いたままにしてください', + continuityDesktopDesc: 'Desktopを閉じると、このグループチャットは一時停止します。', + retryTitle: '不確かな作業を再試行しますか?', + retryDesc: '前の試行が完了している可能性があります。再試行すると操作が重複する場合があります。', + retryAction: '再試行', + reconnectAction: '再接続', + reconnectingAction: '接続中…', + reconnectFailed: 'このボットを再接続できませんでした。ゲートウェイを確認して、もう一度お試しください。', + botsNeedOneHost: '選択したボットはDesktopを閉じると継続できません。', + aBot: 'ボット', + memberUnavailable: member => `${member} は利用できません。`, + memberNeedsAttention: member => `${member} に確認が必要です。`, + memberReconnectToContinue: member => `このグループチャットを続けるには ${member} を再接続してください。`, + memberCouldNotRespond: member => `${member} は応答できませんでした。`, + memberRetryWhenOnline: member => `${member} はオンラインになると再試行します。`, + desktopStorageUnavailable: 'Desktopでこの操作を保存できませんでした。もう一度お試しください。', + hostRejectedCommand: '接続先がこの操作を拒否しました。', nameTaken: name => `「${name}」という名前のグループはすでに存在します。`, memberCount: count => `ボット${count}体`, settingsHint: group => `グループ設定 — ${group}の名前変更やルーム画像の設定`, @@ -683,6 +841,7 @@ const zh: BotsMessages = { duplicateFailed: '复制失败', deleteTitle: '删除机器人和配置档案?', removeFromAllGroups: '从所有群组中移除', + removeFromOtherGroups: '退出其他群组', createFirstHint: '打开机器人面板,点击“新建机器人”。', createFailed: '暂时无法创建配置档案', advanced: '高级', @@ -718,7 +877,9 @@ const zh: BotsMessages = { }, group: { newTitle: '新建群聊', - manageDesc: '一个机器人可以加入多个群聊。成员关系会同步到每台设备。', + newDesc: '选择 2–6 个机器人。', + noBots: '还没有机器人。请先创建一个机器人。', + manageDesc: '一个机器人可以加入多个群聊。', manageTitle: '管理群组', settingsTitle: '群组设置', settingsDesc: '重命名群组或设置房间图片。成员和历史都会保留。', @@ -749,6 +910,55 @@ const zh: BotsMessages = { holdReleaseHint: '提及已暂停的机器人,或发送 @all resume 以恢复它们。', needsYourInput: '此群聊中有机器人需要你输入', pictureGenerationFailed: '群组图片生成失败', + createAction: count => `创建群聊${count ? ` (${count})` : ''}`, + created: (name, count) => `已创建“${name}”,包含 ${count} 个机器人`, + createFailed: '无法创建群聊。请重试。', + creating: '正在创建…', + pickAtLeastTwo: '请至少选择 2 个机器人', + thisHost: '此设备', + hostedFallbackToDesktop: host => `${host} 暂时无法保持此群聊运行。请保持 Desktop 打开。`, + hostedAttachmentsUnavailable: '附件目前需要 Desktop 模式。', + hostedSending: '正在发送…', + hostedWorking: '正在工作', + hostedQueued: host => `正在等待 ${host}`, + hostedQueuedHint: host => `已保存。${host} 上线后将发送。`, + hostedNeedsAttention: '需要处理', + hostedSendFailed: host => `未发送。请重新连接 ${host} 后重试。`, + hostedStopping: '正在停止…', + hostedStopped: '已停止', + hostedStopQueued: host => `已为 ${host} 保存停止请求`, + hostedStopQueuedHint: host => `${host} 上线后将停止。`, + hostedUnavailable: host => `${host} 已离线`, + hostedReconnectToStop: host => `请重新连接 ${host} 以停止此群聊。`, + hostedDeleted: '此群聊已被删除。', + hostedDeleteLocally: '请在此处删除,以移除本地成员关系和历史记录。', + hostedMembersFixed: '此群聊在没有 Desktop 的情况下运行时无法更改成员。', + hostedRenameQueued: host => `重命名已保存。${host} 上线后将同步。`, + hostedRenameFailed: host => `无法重命名。请重新连接 ${host} 后重试。`, + hostRouteMissing: '此群聊连接不可用。', + hostUpdateNeeded: host => `请更新 ${host} 以保持此群聊运行。`, + hostReconnectToContinue: host => `请重新连接 ${host} 以继续。`, + hostedReconnectToDelete: host => `请重新连接 ${host} 以删除此群聊。`, + hostedSyncing: '正在同步近期活动…', + continuityOnTitle: '可以关闭 Desktop', + continuityOnDesc: '此群聊中的机器人会继续工作。', + continuityDesktopTitle: '请保持 Desktop 打开', + continuityDesktopDesc: '关闭 Desktop 会暂停此群聊。', + retryTitle: '重试状态不确定的工作?', + retryDesc: '之前的尝试可能已完成。重试可能会重复操作。', + retryAction: '重试', + reconnectAction: '重新连接', + reconnectingAction: '正在连接…', + reconnectFailed: '无法重新连接此机器人。请检查其网关后重试。', + botsNeedOneHost: '关闭 Desktop 后,所选机器人无法继续工作。', + aBot: '一个机器人', + memberUnavailable: member => `${member} 不可用。`, + memberNeedsAttention: member => `${member} 需要你的处理。`, + memberReconnectToContinue: member => `请重新连接 ${member} 以继续此群聊。`, + memberCouldNotRespond: member => `${member} 无法回复。`, + memberRetryWhenOnline: member => `${member} 上线后将重试。`, + desktopStorageUnavailable: 'Desktop 无法保存此操作。请重试。', + hostRejectedCommand: '连接的设备拒绝了此操作。', nameTaken: name => `已存在名为“${name}”的群聊。`, memberCount: count => `${count} 个机器人`, settingsHint: group => `群聊设置 — 重命名 ${group} 或设置房间图片`, @@ -876,6 +1086,7 @@ const zhHant: BotsMessages = { duplicateFailed: '複製失敗', deleteTitle: '刪除機器人和設定檔?', removeFromAllGroups: '從所有群組中移除', + removeFromOtherGroups: '退出其他群組', createFirstHint: '開啟機器人面板,點「新增機器人」。', createFailed: '暫時無法建立設定檔', advanced: '進階', @@ -911,7 +1122,9 @@ const zhHant: BotsMessages = { }, group: { newTitle: '新增群組聊天', - manageDesc: '一個機器人可以加入多個群組聊天。成員關係會同步到每台裝置。', + newDesc: '選擇 2–6 個機器人。', + noBots: '還沒有機器人。請先建立一個機器人。', + manageDesc: '一個機器人可以加入多個群組聊天。', manageTitle: '管理群組', settingsTitle: '群組設定', settingsDesc: '重新命名群組或設定房間圖片。成員和歷史都會保留。', @@ -942,6 +1155,55 @@ const zhHant: BotsMessages = { holdReleaseHint: '提及已暫停的機器人,或傳送 @all resume 以恢復它們。', needsYourInput: '此群組聊天中有機器人需要您的輸入', pictureGenerationFailed: '群組圖片產生失敗', + createAction: count => `建立群組聊天${count ? ` (${count})` : ''}`, + created: (name, count) => `已建立「${name}」,包含 ${count} 個機器人`, + createFailed: '無法建立群組聊天。請再試一次。', + creating: '正在建立…', + pickAtLeastTwo: '請至少選擇 2 個機器人', + thisHost: '此裝置', + hostedFallbackToDesktop: host => `${host} 暫時無法保持此群組聊天運作。請保持 Desktop 開啟。`, + hostedAttachmentsUnavailable: '附件目前需要 Desktop 模式。', + hostedSending: '正在傳送…', + hostedWorking: '正在工作', + hostedQueued: host => `正在等待 ${host}`, + hostedQueuedHint: host => `已儲存。${host} 上線後將傳送。`, + hostedNeedsAttention: '需要處理', + hostedSendFailed: host => `未傳送。請重新連接 ${host} 後再試一次。`, + hostedStopping: '正在停止…', + hostedStopped: '已停止', + hostedStopQueued: host => `已為 ${host} 儲存停止要求`, + hostedStopQueuedHint: host => `${host} 上線後將停止。`, + hostedUnavailable: host => `${host} 已離線`, + hostedReconnectToStop: host => `請重新連接 ${host} 以停止此群組聊天。`, + hostedDeleted: '此群組聊天已被刪除。', + hostedDeleteLocally: '請在此處刪除,以移除本機成員關係和歷史記錄。', + hostedMembersFixed: '此群組聊天在沒有 Desktop 的情況下運作時無法變更成員。', + hostedRenameQueued: host => `重新命名已儲存。${host} 上線後將同步。`, + hostedRenameFailed: host => `無法重新命名。請重新連接 ${host} 後再試一次。`, + hostRouteMissing: '此群組聊天連線無法使用。', + hostUpdateNeeded: host => `請更新 ${host} 以保持此群組聊天運作。`, + hostReconnectToContinue: host => `請重新連接 ${host} 以繼續。`, + hostedReconnectToDelete: host => `請重新連接 ${host} 以刪除此群組聊天。`, + hostedSyncing: '正在同步近期活動…', + continuityOnTitle: '可以關閉 Desktop', + continuityOnDesc: '此群組聊天中的機器人會繼續工作。', + continuityDesktopTitle: '請保持 Desktop 開啟', + continuityDesktopDesc: '關閉 Desktop 會暫停此群組聊天。', + retryTitle: '重試狀態不確定的工作?', + retryDesc: '先前的嘗試可能已完成。重試可能會重複操作。', + retryAction: '重試', + reconnectAction: '重新連接', + reconnectingAction: '正在連接…', + reconnectFailed: '無法重新連接此機器人。請檢查其閘道後再試一次。', + botsNeedOneHost: '關閉 Desktop 後,所選機器人無法繼續工作。', + aBot: '一個機器人', + memberUnavailable: member => `${member} 無法使用。`, + memberNeedsAttention: member => `${member} 需要您的處理。`, + memberReconnectToContinue: member => `請重新連接 ${member} 以繼續此群組聊天。`, + memberCouldNotRespond: member => `${member} 無法回覆。`, + memberRetryWhenOnline: member => `${member} 上線後將重試。`, + desktopStorageUnavailable: 'Desktop 無法儲存此操作。請再試一次。', + hostRejectedCommand: '已連接的裝置拒絕了此操作。', nameTaken: name => `已存在名為「${name}」的群組聊天。`, memberCount: count => `${count} 個機器人`, settingsHint: group => `群組設定 — 重新命名 ${group} 或設定房間圖片`, diff --git a/apps/desktop/src/plugins/hermes-bots/plugin-panes.test.tsx b/apps/desktop/src/plugins/hermes-bots/plugin-panes.test.tsx index 38fd1a883a5c4..263b888a49c71 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin-panes.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/plugin-panes.test.tsx @@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => ({ botChatOwnsWorkspace: vi.fn(() => false), paneVisibility: vi.fn(), sessionOwnsWorkspace: vi.fn(() => false), + startHostedRoomRuntime: vi.fn(async () => undefined), + stopHostedRoomRuntime: vi.fn(), setWorkspaceScope: vi.fn() })) @@ -49,10 +51,15 @@ vi.mock('@hermes/plugin-sdk', async importOriginal => { // storage sweeps and the panes' own render trees. vi.mock('./avatar', () => ({ startFaceClock: vi.fn(), stopFaceClock: vi.fn() })) vi.mock('./relay', () => ({ startBotRelay: vi.fn(), stopBotRelay: vi.fn() })) +vi.mock('./hosted-room-runtime', () => ({ + startHostedRoomRuntime: mocks.startHostedRoomRuntime, + stopHostedRoomRuntime: mocks.stopHostedRoomRuntime +})) vi.mock('./session-sweep', () => ({ startHideSweepScheduler: vi.fn() })) vi.mock('./canonical-chat', () => ({ openBotCanonicalChat: vi.fn() })) vi.mock('./chat-empty', () => ({ BotChatEmpty: () => null })) vi.mock('./hygiene', () => ({ annotateOrphanedGroupChatMembers: () => ({ changed: false, rooms: {} }) })) +vi.mock('./group-chat-view', () => ({ renameGroupChat: vi.fn(async (_old, next) => next) })) vi.mock('./cron', () => ({ bindProfileSync: () => () => undefined, RoutinesPane: () => null })) vi.mock('./roster-pane', () => ({ botChatOwnsWorkspace: mocks.botChatOwnsWorkspace, @@ -97,7 +104,7 @@ interface Registration { } /** A recording `PluginContext`: registrations, their disposers, teardown. */ -function recordingContext() { +function recordingContext(storageGet: (key: string) => Promise = async () => undefined) { const disposers: (() => void)[] = [] const registrations: Registration[] = [] const unregisters = new Map void>() @@ -116,7 +123,7 @@ function recordingContext() { return unregister }, - storage: { get: async () => undefined, set: async () => undefined } + storage: { get: storageGet, set: async () => undefined } } return { @@ -180,6 +187,39 @@ describe('the Bots pane dock', () => { }) }) +describe('hosted Group Chat startup', () => { + it('does not probe or replay hosted rooms before local Group Chat hydration settles', async () => { + paneStores() + let releaseRooms: (value: unknown) => void = () => undefined + + const rooms = new Promise(resolve => { + releaseRooms = resolve + }) + + const harness = recordingContext(async key => (key === 'group-chats' ? rooms : undefined)) + + plugin.register(harness.ctx) + await Promise.resolve() + await Promise.resolve() + + expect(mocks.startHostedRoomRuntime).not.toHaveBeenCalled() + + releaseRooms({}) + await settle() + + expect(mocks.startHostedRoomRuntime).toHaveBeenCalledTimes(1) + expect(mocks.startHostedRoomRuntime).toHaveBeenCalledWith( + harness.ctx.storage, + expect.objectContaining({ + renameGroupChat: expect.any(Function) + }) + ) + + harness.dispose() + expect(mocks.stopHostedRoomRuntime).toHaveBeenCalled() + }) +}) + describe('the Scheduled jobs pane', () => { it('stays unregistered until a bot chat owns the workspace', async () => { const store = paneStores() diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.tsx b/apps/desktop/src/plugins/hermes-bots/plugin.tsx index 577d7db2e8e49..016c4639f4346 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.tsx +++ b/apps/desktop/src/plugins/hermes-bots/plugin.tsx @@ -42,6 +42,7 @@ import { migrateBotMeta, resolveRosterMentions } from './data' +import { startDesktopRoomCommandRuntime, stopDesktopRoomCommandRuntime } from './desktop-room-command-runtime' import { $groupChats, $groupChatWorkspace, @@ -54,7 +55,9 @@ import { sweepGroupChatMembersForRemovedConnection, updateGroupChat } from './group-chat' +import { renameGroupChat } from './group-chat-view' import { groupWorkspaceOwnerKey } from './group-membership' +import { startHostedRoomRuntime, stopHostedRoomRuntime } from './hosted-room-runtime' import { annotateOrphanedGroupChatMembers } from './hygiene' import { BOTS_LOCALES } from './i18n' import { displayName } from './labels' @@ -97,6 +100,34 @@ export default { setPluginCtx(ctx) const disposeLocales = ctx.i18n.register(BOTS_LOCALES) setGroupChatSyncDisposed(false) + let roomServicesStarted = false + let roomServicesDisposed = false + let unbindGatewayListener: null | (() => void) = null + + const startRoomServices = () => { + if (roomServicesStarted || roomServicesDisposed) { + return + } + + roomServicesStarted = true + let bindingGatewayListener = true + unbindGatewayListener = host.state.gateway.listen(() => { + // Atom listeners seed synchronously. Treating that seed as a gateway + // transition bumps every room epoch and can cancel a startup send. + if (!bindingGatewayListener) { + handleSessionsGatewayTransition() + } + }) + bindingGatewayListener = false + void startHostedRoomRuntime(ctx.storage, { + renameGroupChat: (oldName, newName, members) => + renameGroupChat(oldName, newName, members, { + hostedAlreadyRenamed: true + }) + }) + void startDesktopRoomCommandRuntime(ctx.storage) + } + startFaceClock() // The cross-connection relay rides every gateway socket this Desktop // holds: roster sync + envelope drain/deliver/reply loops. @@ -108,6 +139,11 @@ export default { ctx.onDispose(disposeLocales) ctx.onDispose(stopFaceClock) ctx.onDispose(stopBotRelay) + ctx.onDispose(() => { + roomServicesDisposed = true + stopHostedRoomRuntime() + stopDesktopRoomCommandRuntime() + }) } // @-mention autocomplete: typing "@rese…" in ANY composer offers the @@ -215,8 +251,9 @@ export default { /* no storage — default (silent) stays */ } - // Hydrate persisted group-chat room logs (epoch/running are runtime-only - // and always reset — a loop can't survive a window reload anyway). + // Hydrate persisted group-chat room logs. Desktop epochs/running are + // runtime-only; hosted authority/cursor fields survive so the gateway + // driver can keep working while this window is gone and replay safely. try { // @ts-expect-error TODO(bot-mode-types): PluginStorage.get requires a fallback argument. Promise.resolve(ctx.storage?.get?.('group-chats')) @@ -240,6 +277,26 @@ export default { holds: room.holds && typeof room.holds === 'object' ? room.holds : {}, members: Array.isArray(room.members) ? room.members : [], roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + desktopCoordinatorId: + typeof room.desktopCoordinatorId === 'string' && room.desktopCoordinatorId + ? room.desktopCoordinatorId + : null, + desktopAuthorityToken: + typeof room.desktopAuthorityToken === 'string' && room.desktopAuthorityToken + ? room.desktopAuthorityToken + : null, + desktopCommandSettled: + room.desktopCommandSettled && typeof room.desktopCommandSettled === 'object' + ? room.desktopCommandSettled + : {}, + hosted: typeof room.hosted === 'string' && room.hosted ? room.hosted : null, + hostedEpoch: Math.max(0, Number(room.hostedEpoch || 0)) || null, + hostedConnectionId: + typeof room.hostedConnectionId === 'string' && room.hostedConnectionId + ? room.hostedConnectionId + : null, + hostedSeq: Math.max(0, Number(room.hostedSeq || 0)), + continuityMode: room.hosted ? 'gateway' : room.continuityMode === 'gateway' ? 'gateway' : 'desktop', image: typeof room.image === 'string' && room.image ? room.image : null, syncRevision: Math.max(0, Number(room.syncRevision || 0)), epoch: 0, @@ -294,8 +351,10 @@ export default { scheduleGroupChatServerSync($groupChats.get()) }) .catch(() => undefined) + .finally(startRoomServices) } catch { /* no storage — rooms start empty */ + startRoomServices() } // Routines follow the chat you're in: track the focused chat's owner @@ -307,8 +366,6 @@ export default { // duplicate listener per cycle (same survives-disable class as the face // clock before its onDispose hook — these kept firing until app restart). const unbindProfileListener = bindProfileSync($focusedBotOwner) - const unbindGatewayListener = host.state.gateway.listen(handleSessionsGatewayTransition) - // #93492 root fix: the registry pushes a lifecycle event when a // connection is removed. The gateway store already disposes the dead // sockets; the persisted group-chat rosters referencing that connection diff --git a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx index 60b561961d2b0..bc9c23fe79a19 100644 --- a/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx +++ b/apps/desktop/src/plugins/hermes-bots/roster-pane.tsx @@ -76,7 +76,13 @@ import { } from './roster-sections' import type { ResolvedRosterGatewaySection } from './roster-sections' import { botRosterMeta, botWorkspaceOwnerKey, setBotsWorkspaceOwner } from './routing' -import { ACTIVE_WINDOW_S, activeBots, BOT_ROSTER_SEARCH_THRESHOLD, rosterActivityMatches } from './row-helpers' +import { + ACTIVE_WINDOW_S, + activeBots, + BOT_ROSTER_SEARCH_THRESHOLD, + canCreateGroupChat, + rosterActivityMatches +} from './row-helpers' import { backfillMessagingProtocol } from './soul' import type { BotMeta, GatewaySource, GroupMember, RosterActivityFilter, RosterKindFilter, RosterRow } from './types' @@ -637,7 +643,7 @@ export function BotsPane() { {b.bot.newTitle}
- setGroupCreateOpen(true)}> + setGroupCreateOpen(true)}> {b.group.newTitle} diff --git a/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts b/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts index 9182249e00b8d..c74350df6aad0 100644 --- a/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/row-helpers.test.ts @@ -19,6 +19,7 @@ import { activeBots, botCanonicalSessionId, botRowOwnsWorkspace, + canCreateGroupChat, previewKind, rosterActivityMatches, workerActiveAt @@ -157,6 +158,22 @@ describe('which bots are working right now', () => { }) }) +describe('Group Chat creation availability', () => { + it('counts reachable local and remote Bots together', () => { + expect( + canCreateGroupChat([ + row({ connectionId: 'local', name: 'local' }), + row({ connectionId: 'remote-a', name: 'remote', remoteSource: true }) + ]) + ).toBe(true) + }) + + it('excludes offline ghost placeholders and tolerates an unresolved roster', () => { + expect(canCreateGroupChat([row({ name: 'local' }), row({ ghost: true, name: 'offline' })])).toBe(false) + expect(canCreateGroupChat(undefined)).toBe(false) + }) +}) + describe('the roster activity filter', () => { it('passes everything through with no filter', () => { expect(rosterActivityMatches({ activity: 0 }, null, NOW)).toBe(true) diff --git a/apps/desktop/src/plugins/hermes-bots/row-helpers.ts b/apps/desktop/src/plugins/hermes-bots/row-helpers.ts index 2c92daeafda57..d7699af10e403 100644 --- a/apps/desktop/src/plugins/hermes-bots/row-helpers.ts +++ b/apps/desktop/src/plugins/hermes-bots/row-helpers.ts @@ -57,6 +57,13 @@ export const ACTIVE_WINDOW_S = 90 const RECENT_ACTIVITY_WINDOW_S = 7 * 24 * 60 * 60 export const BOT_ROSTER_SEARCH_THRESHOLD = 8 +/** A Group Chat can seat every reachable roster row, regardless of which + * gateway is currently active. Ghost rows preserve offline identity but are + * not routable participants. */ +export function canCreateGroupChat(roster: null | RosterRow[] | undefined): boolean { + return (roster || []).filter(bot => !bot?.ghost).length >= 2 +} + /** The stored session id this bot's canonical Bot Chat answers to — the * compression-lineage tip the live-state atoms are keyed by, falling back to * the durable registry id. THE id for anything core-keyed: the row's status diff --git a/apps/desktop/src/plugins/hermes-bots/types.ts b/apps/desktop/src/plugins/hermes-bots/types.ts index e21298f544b28..ac3aeb24c8298 100644 --- a/apps/desktop/src/plugins/hermes-bots/types.ts +++ b/apps/desktop/src/plugins/hermes-bots/types.ts @@ -138,9 +138,15 @@ export interface GroupMessageAuthor { export interface GroupMessage { /** Milliseconds. */ at: number + /** Stable gateway event identity after a hosted-room replay. */ + eventId?: string + /** True for an idempotent message accepted through a messaging bridge. */ + external?: boolean from: GroupMessageAuthor id?: string images?: Attachment[] + /** Monotonic gateway order for hosted-room events. */ + seq?: number text: string /** Messages predating threading carry the sentinel thread `'legacy'`. */ thread?: string @@ -152,6 +158,16 @@ export interface GroupHold { } export interface GroupChat { + /** User-facing continuity choice. Missing records are classic Desktop rooms. */ + continuityMode?: 'desktop' | 'distributed' | 'gateway' + /** Public SHA-256 commitment to the local authority token. */ + desktopAuthorityHash?: null | string + /** Secret held only by the Desktop that coordinates this classic room. */ + desktopAuthorityToken?: null | string + /** Stable installation/window identity that owns classic room commands. */ + desktopCoordinatorId?: null | string + /** Bounded idempotency receipts for messaging commands already settled. */ + desktopCommandSettled?: Record /** Bumped to abandon in-flight member turns from a previous round. */ epoch?: number holds?: Record @@ -161,6 +177,25 @@ export interface GroupChat { /** Immutable identity, so a rename doesn't fork the room. */ roomId?: null | string running?: boolean + /** Stable authority installation id for a gateway-hosted room. */ + hosted?: null | string + /** The local Desktop connection that currently reaches the authority. */ + hostedConnectionId?: null | string + /** Server-issued fencing epoch for the hosted authority. */ + hostedEpoch?: null | number + /** Last contiguous hosted-room event sequence applied locally. */ + hostedSeq?: number + hostedStatus?: null | { + canReconnect?: boolean + canRetry?: boolean + canStop?: boolean + label: string + reconnectMemberId?: string + state: string + taskId?: string + } + /** Short, actionable continuity problem for the room surface. */ + continuityIssue?: null | string /** The immutable owner descriptor captured beside each plumbing session, * keyed the same way as `sessions`. Partial: legacy records hold a bare * `{ name }`, and the sweep re-validates the route before trusting one. */ diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 9ec40732b6b70..1fc6070c87ecd 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -171,6 +171,7 @@ present (may be `null`); the rest are included only when set. | `platform` | string | yes | Platform name (matches the descriptor's `platform`). | | `chat_id` | string | yes | Primary conversation id (channel/chat). Session-key discriminator. | | `chat_type` | string | yes | `dm` / `group` / `channel` / `thread` / `forum`. | +| `one_to_one_verified` | boolean | no | Connector-verified private conversation with exactly one human. Required for private controls on relay-fronted platforms where `dm` can contain multiple people (for example Slack or Matrix). The gateway treats it as a transport-local fact and never persists it. | | `chat_name` | string\|null | yes | Human-readable chat name. | | `user_id` | string\|null | yes | Message author id. Session-key discriminator. | | `user_name` | string\|null | yes | Author display name. | @@ -182,11 +183,12 @@ present (may be `null`); the rest are included only when set. | `guild_id` | string | no | **Legacy alias, no longer read by the connector.** As of D-Q2.5c the connector reads and writes only `scope_id`; the gateway's agent-wide `SessionSource.to_dict()` still emits `guild_id` (mirrored to `scope_id`) for non-relay session persistence, so it may still appear on the wire but the connector ignores it. Do not depend on it. | | `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. | | `message_id` | string | no | Id of the triggering message (for pin/reply/react). | +| `is_bot` | boolean | yes | Whether the author is a bot or webhook. Room controls fail closed when an older connector omits this classification. | +| `message_is_edit` | boolean | yes | Whether this delivery edits a prior message. Room controls fail closed when an older connector omits this classification. | -> `is_bot` (author-is-a-bot/webhook classification) exists on the gateway-side -> dataclass but is **intentionally NOT on the wire** in v1 — it is not part of -> `to_dict()`. Do not add it to the connector's `SessionSource` until it is -> first added here and to `to_dict()` (additive bump). +`is_bot` is an additive author-provenance bump. Updated connectors must send an +explicit `false` for people rather than omitting the field; omission remains +accepted by older gateway features but room controls reject it. ### SessionSource discriminators per platform diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index e80435d9827f9..9cd0f2b7abbb9 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -104,6 +104,67 @@ def _coerce_allow_set(raw) -> set[str]: return {part.strip() for part in str(raw).split(",") if part.strip()} +_PLATFORM_ALLOWED_USERS_ENV = { + Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", + Platform.DISCORD: "DISCORD_ALLOWED_USERS", + Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", + Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS", + Platform.SLACK: "SLACK_ALLOWED_USERS", + Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", + Platform.EMAIL: "EMAIL_ALLOWED_USERS", + Platform.SMS: "SMS_ALLOWED_USERS", + Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS", + Platform.MATRIX: "MATRIX_ALLOWED_USERS", + Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", + Platform.FEISHU: "FEISHU_ALLOWED_USERS", + Platform.WECOM: "WECOM_ALLOWED_USERS", + Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS", + Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", + Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", + Platform.QQBOT: "QQ_ALLOWED_USERS", + Platform.YUANBAO: "YUANBAO_ALLOWED_USERS", +} + +_PLATFORM_ALLOW_ALL_ENV = { + Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", + Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS", + Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS", + Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOW_ALL_USERS", + Platform.SLACK: "SLACK_ALLOW_ALL_USERS", + Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", + Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS", + Platform.SMS: "SMS_ALLOW_ALL_USERS", + Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS", + Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS", + Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS", + Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS", + Platform.WECOM: "WECOM_ALLOW_ALL_USERS", + Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS", + Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", + Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", + Platform.QQBOT: "QQ_ALLOW_ALL_USERS", + Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS", +} + + +def _platform_authorization_env_names(platform: Platform) -> tuple[str, str]: + """Return the authoritative allowed-users and allow-all env names.""" + allowed_users = _PLATFORM_ALLOWED_USERS_ENV.get(platform, "") + allow_all = _PLATFORM_ALLOW_ALL_ENV.get(platform, "") + if allowed_users and allow_all: + return allowed_users, allow_all + try: + from gateway.platform_registry import platform_registry + + entry = platform_registry.get(platform.value) + if entry is not None: + allowed_users = allowed_users or entry.allowed_users_env + allow_all = allow_all or entry.allow_all_env + except Exception: + pass + return allowed_users, allow_all + + # --------------------------------------------------------------------------- # Nostr npub → hex normalization (Buzz and future Nostr-based platforms). # @@ -614,26 +675,9 @@ def _is_user_authorized( if not user_id: return False - platform_env_map = { - Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", - Platform.DISCORD: "DISCORD_ALLOWED_USERS", - Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", - Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS", - Platform.SLACK: "SLACK_ALLOWED_USERS", - Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", - Platform.EMAIL: "EMAIL_ALLOWED_USERS", - Platform.SMS: "SMS_ALLOWED_USERS", - Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS", - Platform.MATRIX: "MATRIX_ALLOWED_USERS", - Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", - Platform.FEISHU: "FEISHU_ALLOWED_USERS", - Platform.WECOM: "WECOM_ALLOWED_USERS", - Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS", - Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", - Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", - Platform.QQBOT: "QQ_ALLOWED_USERS", - Platform.YUANBAO: "YUANBAO_ALLOWED_USERS", - } + platform_allowed_users_var, platform_allow_all_var = ( + _platform_authorization_env_names(source.platform) + ) platform_group_user_env_map = { Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS", } @@ -641,43 +685,7 @@ def _is_user_authorized( Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", } - platform_allow_all_map = { - Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", - Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS", - Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS", - Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOW_ALL_USERS", - Platform.SLACK: "SLACK_ALLOW_ALL_USERS", - Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", - Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS", - Platform.SMS: "SMS_ALLOW_ALL_USERS", - Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS", - Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS", - Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS", - Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS", - Platform.WECOM: "WECOM_ALLOW_ALL_USERS", - Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS", - Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", - Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", - Platform.QQBOT: "QQ_ALLOW_ALL_USERS", - Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS", - } - - # Plugin platforms: check the registry for auth env var names - if source.platform not in platform_env_map: - try: - from gateway.platform_registry import platform_registry - - entry = platform_registry.get(source.platform.value) - if entry: - if entry.allowed_users_env: - platform_env_map[source.platform] = entry.allowed_users_env - if entry.allow_all_env: - platform_allow_all_map[source.platform] = entry.allow_all_env - except Exception: - pass - # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) - platform_allow_all_var = platform_allow_all_map.get(source.platform, "") if platform_allow_all_var and _auth_env(platform_allow_all_var).lower() in {"true", "1", "yes"}: return True @@ -712,7 +720,7 @@ def _is_user_authorized( return True # Check platform-specific and global allowlists - platform_allowlist = _auth_env(platform_env_map.get(source.platform, "")) + platform_allowlist = _auth_env(platform_allowed_users_var) group_user_allowlist = "" group_chat_allowlist = "" if source.chat_type in {"group", "forum"}: diff --git a/gateway/desktop_room_mailbox.py b/gateway/desktop_room_mailbox.py new file mode 100644 index 0000000000000..91d6ebebf5635 --- /dev/null +++ b/gateway/desktop_room_mailbox.py @@ -0,0 +1,1097 @@ +"""Durable command handoff for Desktop-driven Bot rooms. + +Messaging adapters run on the gateway while classic room orchestration lives +in a connected Desktop renderer. This mailbox keeps that compatibility path +idempotent and recoverable without making the gateway a second room runner. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import secrets +import sqlite3 +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + + +MAX_ROOM_IDS = 128 +MAX_QUERY_ROOM_IDS = 4096 +MAX_COMMANDS_PER_CLAIM = 8 +MAX_PAYLOAD_BYTES = 64 * 1024 +# Desktop refreshes classic-room presence on a 60s retained-socket backstop; +# push events handle command latency. Keep enough overlap for scheduler jitter +# without turning a closed Desktop into a long-lived false-positive. +PRESENCE_TTL_SECONDS = 90.0 +CLAIM_TTL_SECONDS = 45.0 +PENDING_TTL_SECONDS = 24 * 60 * 60 +TERMINAL_RETENTION_SECONDS = 7 * 24 * 60 * 60 +MAX_PENDING_COMMANDS_PER_ROOM = 64 +MAX_PENDING_COMMANDS_TOTAL = 4096 +MAX_RETRY_COMMANDS = 32 + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") +_AUTHORITY_HASH_RE = re.compile(r"^[a-f0-9]{64}$") +_ACTIONS = frozenset({"send", "stop"}) +_TERMINAL_STATES = frozenset({"completed", "failed"}) + + +class DesktopRoomMailboxError(ValueError): + """Raised when a mailbox command is invalid or stale.""" + + +def default_db_path() -> Path: + """Keep compatibility heartbeats out of the session state database.""" + + from gateway.hosted_rooms import default_db_path as hosted_db_path + + return hosted_db_path().with_name("desktop_room_mailbox.db") + + +def pending_signal_path(db_path: Path | str | None = None) -> Path: + """Cross-process change signal watched by the gateway WebSocket server.""" + + return Path(db_path or default_db_path()).with_name("desktop_room_mailbox.pending") + + +def _identifier(value: Any, *, label: str) -> str: + text = str(value or "").strip() + if not text or len(text) > 160 or not _IDENTIFIER_RE.fullmatch(text): + raise DesktopRoomMailboxError(f"invalid {label}") + return text + + +def _room_identifier(value: Any) -> str: + text = str(value or "").strip() + if ( + not text + or len(text) > 200 + or any(char in text for char in ("\x00", "\r", "\n")) + ): + raise DesktopRoomMailboxError("invalid room_id") + return text + + +def _room_ids(value: Any) -> list[str]: + if not isinstance(value, (list, tuple)): + raise DesktopRoomMailboxError("room_ids must be a list") + if len(value) > MAX_ROOM_IDS: + raise DesktopRoomMailboxError("too many room_ids") + return list(dict.fromkeys(_room_identifier(item) for item in value)) + + +def _room_authorities(value: Any) -> list[tuple[str, str]]: + if not isinstance(value, (list, tuple)): + raise DesktopRoomMailboxError("room_authorities must be a list") + if len(value) > MAX_ROOM_IDS: + raise DesktopRoomMailboxError("too many room authorities") + authorities: dict[str, str] = {} + for item in value: + if not isinstance(item, dict): + raise DesktopRoomMailboxError("invalid room authority") + room_id = _room_identifier(item.get("room_id")) + token = _identifier(item.get("authority_token"), label="authority_token") + digest = hashlib.sha256(token.encode("utf-8")).hexdigest() + prior = authorities.setdefault(room_id, digest) + if prior != digest: + raise DesktopRoomMailboxError("conflicting room authority") + return list(authorities.items()) + + +def _authority_hash(value: Any) -> str: + authority_hash = str(value or "").strip().casefold() + if not _AUTHORITY_HASH_RE.fullmatch(authority_hash): + raise DesktopRoomMailboxError("invalid room authority commitment") + return authority_hash + + +def _room_commitments(value: Any) -> list[tuple[str, str]]: + if not isinstance(value, (list, tuple)): + raise DesktopRoomMailboxError("room commitments must be a list") + if len(value) > MAX_ROOM_IDS: + raise DesktopRoomMailboxError("too many room commitments") + commitments: dict[str, str] = {} + for item in value: + if not isinstance(item, dict): + raise DesktopRoomMailboxError("invalid room commitment") + room_id = _room_identifier(item.get("room_id")) + authority_hash = _authority_hash(item.get("authority_hash")) + prior = commitments.setdefault(room_id, authority_hash) + if prior != authority_hash: + raise DesktopRoomMailboxError("conflicting room commitment") + return list(commitments.items()) + + +def _query_room_ids(value: Any) -> list[str]: + if not isinstance(value, (list, tuple)): + raise DesktopRoomMailboxError("room_ids must be a list") + if len(value) > MAX_QUERY_ROOM_IDS: + raise DesktopRoomMailboxError("too many room_ids") + return list(dict.fromkeys(_room_identifier(item) for item in value)) + + +def _payload_json(value: Any) -> str: + try: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + except (TypeError, ValueError, RecursionError) as exc: + raise DesktopRoomMailboxError("payload must be JSON-serializable") from exc + if len(encoded.encode("utf-8")) > MAX_PAYLOAD_BYTES: + raise DesktopRoomMailboxError("payload is too large") + return encoded + + +def _initialize(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS desktop_room_commands ( + command_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + action TEXT NOT NULL, + payload_json TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + lease_owner TEXT, + lease_token TEXT, + lease_expires_at REAL, + attempts INTEGER NOT NULL DEFAULT 0, + result_json TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + command_columns = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_commands)") + } + if "lease_token" not in command_columns: + conn.execute("ALTER TABLE desktop_room_commands ADD COLUMN lease_token TEXT") + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_desktop_room_commands_claim + ON desktop_room_commands(state, room_id, created_at)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS desktop_room_presence ( + consumer_id TEXT NOT NULL, + room_id TEXT NOT NULL, + expires_at REAL NOT NULL, + PRIMARY KEY (consumer_id, room_id) + )""" + ) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_desktop_room_presence_room + ON desktop_room_presence(room_id, expires_at)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS desktop_room_owners ( + room_id TEXT PRIMARY KEY, + consumer_id TEXT NOT NULL, + expires_at REAL NOT NULL + )""" + ) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_desktop_room_owners_expiry + ON desktop_room_owners(expires_at)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS desktop_room_authorities ( + room_id TEXT PRIMARY KEY, + consumer_id TEXT, + authority_hash TEXT NOT NULL, + created_at REAL NOT NULL + )""" + ) + authority_columns = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_authorities)") + } + if "consumer_id" not in authority_columns: + conn.execute( + "ALTER TABLE desktop_room_authorities ADD COLUMN consumer_id TEXT" + ) + + +def _schema_is_current(conn: sqlite3.Connection) -> bool: + commands = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_commands)") + } + presence = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_presence)") + } + owners = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_owners)") + } + authorities = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_authorities)") + } + return ( + { + "command_id", + "room_id", + "action", + "payload_json", + "state", + "lease_owner", + "lease_token", + "lease_expires_at", + "attempts", + "result_json", + "created_at", + "updated_at", + }.issubset(commands) + and {"consumer_id", "room_id", "expires_at"}.issubset(presence) + and {"room_id", "consumer_id", "expires_at"}.issubset(owners) + and {"room_id", "consumer_id", "authority_hash", "created_at"}.issubset( + authorities + ) + ) + + +def _connect(db_path: Path | str) -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + + path = Path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=10) + conn.row_factory = sqlite3.Row + try: + apply_wal_with_fallback(conn, db_label="state.db (desktop room mailbox)") + if _schema_is_current(conn): + return conn + conn.execute("BEGIN IMMEDIATE") + _initialize(conn) + conn.commit() + except Exception: + conn.rollback() + conn.close() + raise + return conn + + +def _notify_pending(db_path: Path | str) -> None: + path = pending_signal_path(db_path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(str(time.time_ns()), encoding="ascii") + os.chmod(path, 0o600) + except OSError: + # The command is already durable. A failed best-effort wake signal + # must never turn success into an error or invite a duplicate send; + # the retained-socket poll remains the backstop. + pass + + +@contextmanager +def _transaction( + db_path: Path | str, *, immediate: bool = False +) -> Iterator[sqlite3.Connection]: + conn = _connect(db_path) + try: + if immediate: + conn.execute("BEGIN IMMEDIATE") + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _command(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: + result = { + "command_id": str(row["command_id"]), + "room_id": str(row["room_id"]), + "action": str(row["action"]), + "payload": json.loads(row["payload_json"]), + "state": str(row["state"]), + "attempts": int(row["attempts"]), + "created_at": float(row["created_at"]), + "updated_at": float(row["updated_at"]), + "idempotent": idempotent, + } + if row["result_json"]: + result["result"] = json.loads(row["result_json"]) + if row["lease_token"]: + result["lease_token"] = str(row["lease_token"]) + return result + + +def _expire_stale_state( + conn: sqlite3.Connection, + *, + now: float, + pending_ttl: float = PENDING_TTL_SECONDS, +) -> None: + """Bound offline work and remove expired ownership before every write path.""" + + conn.execute("DELETE FROM desktop_room_presence WHERE expires_at <= ?", (now,)) + conn.execute("DELETE FROM desktop_room_owners WHERE expires_at <= ?", (now,)) + cutoff = now - max(1.0, float(pending_ttl)) + expired_result = _payload_json( + { + "code": "command_expired", + "message": "This Group Chat command expired before Desktop could apply it.", + } + ) + conn.execute( + """UPDATE desktop_room_commands + SET state = 'failed', result_json = ?, lease_owner = NULL, + lease_token = NULL, lease_expires_at = NULL, updated_at = ? + WHERE state = 'pending' AND created_at <= ?""", + (expired_result, now, cutoff), + ) + conn.execute( + """UPDATE desktop_room_commands + SET state = 'failed', result_json = ?, lease_owner = NULL, + lease_token = NULL, lease_expires_at = NULL, updated_at = ? + WHERE state = 'claimed' AND created_at <= ? + AND COALESCE(lease_expires_at, 0) <= ?""", + (expired_result, now, cutoff, now), + ) + conn.execute( + """DELETE FROM desktop_room_commands + WHERE state IN ('completed', 'failed') AND updated_at <= ?""", + (now - TERMINAL_RETENTION_SECONDS,), + ) + + +def _owned_rooms( + conn: sqlite3.Connection, + *, + consumer_id: str, + authorities: list[tuple[str, str]], + now: float, + presence_ttl: float, +) -> list[str]: + """Bind or safely transfer rooms when the prior Desktop lease is gone.""" + + candidates: list[str] = [] + for room_id, authority_hash in authorities: + authority = conn.execute( + """SELECT consumer_id, authority_hash + FROM desktop_room_authorities WHERE room_id = ?""", + (room_id,), + ).fetchone() + if authority is None or str(authority["authority_hash"] or "") != authority_hash: + continue + bound_consumer = str(authority["consumer_id"] or "") + if bound_consumer and bound_consumer != consumer_id: + live_owner = conn.execute( + """SELECT 1 FROM desktop_room_owners + WHERE room_id = ? AND consumer_id = ? AND expires_at > ?""", + (room_id, bound_consumer, now), + ).fetchone() + live_claim = conn.execute( + """SELECT 1 FROM desktop_room_commands + WHERE room_id = ? AND state = 'claimed' + AND lease_owner = ? AND lease_expires_at > ? LIMIT 1""", + (room_id, bound_consumer, now), + ).fetchone() + if live_owner is not None or live_claim is not None: + continue + conn.execute( + """UPDATE desktop_room_authorities SET consumer_id = ? + WHERE room_id = ? AND authority_hash = ?""", + (consumer_id, room_id, authority_hash), + ) + candidates.append(room_id) + + if candidates: + conn.executemany( + """INSERT INTO desktop_room_owners ( + room_id, consumer_id, expires_at + ) VALUES (?, ?, ?) + ON CONFLICT(room_id) DO UPDATE + SET consumer_id = excluded.consumer_id, + expires_at = excluded.expires_at + WHERE desktop_room_owners.consumer_id = excluded.consumer_id + OR desktop_room_owners.expires_at <= ?""", + ( + (room_id, consumer_id, now + float(presence_ttl), now) + for room_id in candidates + ), + ) + owned: list[str] = [] + if candidates: + placeholders = ",".join("?" for _ in candidates) + owned = [ + str(row["room_id"]) + for row in conn.execute( + f"""SELECT room_id FROM desktop_room_owners + WHERE room_id IN ({placeholders}) AND consumer_id = ? + AND expires_at > ?""", + (*candidates, consumer_id, now), + ) + ] + if owned: + conn.executemany( + """INSERT INTO desktop_room_presence ( + consumer_id, room_id, expires_at + ) VALUES (?, ?, ?) + ON CONFLICT(consumer_id, room_id) DO UPDATE + SET expires_at = excluded.expires_at""", + ( + (consumer_id, room_id, now + float(presence_ttl)) + for room_id in owned + ), + ) + return owned + + +def register_projected_authorities( + db_path: Path | str, + commitments: Any, + *, + clock: Any = time.time, +) -> list[str]: + """Record one-way owner proofs read from the trusted room projection. + + Conflicts are isolated per room: an old or corrupted projection cannot + prevent healthy rooms in the same snapshot from advertising presence. + """ + + parsed = _room_commitments(commitments) + now = float(clock()) + registered: list[str] = [] + with _transaction(db_path, immediate=True) as conn: + for room_id, authority_hash in parsed: + existing = conn.execute( + """SELECT authority_hash FROM desktop_room_authorities + WHERE room_id = ?""", + (room_id,), + ).fetchone() + if existing is None: + conn.execute( + """INSERT INTO desktop_room_authorities ( + room_id, consumer_id, authority_hash, created_at + ) VALUES (?, NULL, ?, ?)""", + (room_id, authority_hash, now), + ) + registered.append(room_id) + elif str(existing["authority_hash"] or "") == authority_hash: + registered.append(room_id) + return registered + + +def enqueue_command( + db_path: Path | str, + *, + command_id: str, + room_id: str, + authority_hash: str, + action: str, + payload: Any, + clock: Any = time.time, +) -> dict[str, Any]: + """Persist one idempotent command for a compatible Desktop.""" + + command_id = _identifier(command_id, label="command_id") + room_id = _room_identifier(room_id) + authority_hash = _authority_hash(authority_hash) + action = str(action or "").strip().casefold() + if action not in _ACTIONS: + raise DesktopRoomMailboxError("invalid action") + encoded = _payload_json(payload) + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + _expire_stale_state(conn, now=now) + existing_authority = conn.execute( + """SELECT authority_hash FROM desktop_room_authorities + WHERE room_id = ?""", + (room_id,), + ).fetchone() + if existing_authority is None: + conn.execute( + """INSERT INTO desktop_room_authorities ( + room_id, consumer_id, authority_hash, created_at + ) VALUES (?, NULL, ?, ?)""", + (room_id, authority_hash, now), + ) + elif str(existing_authority["authority_hash"] or "") != authority_hash: + raise DesktopRoomMailboxError( + "room authority commitment does not match its existing owner" + ) + existing = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (command_id,), + ).fetchone() + if existing is not None: + if ( + str(existing["room_id"]) != room_id + or str(existing["action"]) != action + or str(existing["payload_json"]) != encoded + ): + raise DesktopRoomMailboxError( + "command_id was already used for different room work" + ) + result = _command(existing, idempotent=True) + else: + if action == "stop": + superseded_result = _payload_json( + { + "code": "superseded_by_stop", + "message": "Canceled before Desktop started it.", + } + ) + conn.execute( + """UPDATE desktop_room_commands + SET state = 'failed', result_json = ?, lease_owner = NULL, + lease_token = NULL, lease_expires_at = NULL, updated_at = ? + WHERE room_id = ? AND action IN ('send', 'stop') + AND state = 'pending'""", + (superseded_result, now, room_id), + ) + else: + room_count = conn.execute( + """SELECT COUNT(*) FROM desktop_room_commands + WHERE room_id = ? AND state IN ('pending', 'claimed')""", + (room_id,), + ).fetchone()[0] + total_count = conn.execute( + """SELECT COUNT(*) FROM desktop_room_commands + WHERE state IN ('pending', 'claimed')""" + ).fetchone()[0] + if int(room_count) >= MAX_PENDING_COMMANDS_PER_ROOM: + raise DesktopRoomMailboxError( + "This Group Chat already has too many commands waiting for Desktop." + ) + if int(total_count) >= MAX_PENDING_COMMANDS_TOTAL: + raise DesktopRoomMailboxError( + "Too many Group Chat commands are waiting for Desktop." + ) + conn.execute( + """INSERT INTO desktop_room_commands ( + command_id, room_id, action, payload_json, state, + created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', ?, ?)""", + (command_id, room_id, action, encoded, now, now), + ) + row = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (command_id,), + ).fetchone() + result = _command(row) + _notify_pending(db_path) + return result + + +def claim_commands( + db_path: Path | str, + *, + consumer_id: str, + room_authorities: Any, + actions: Any = None, + limit: int = MAX_COMMANDS_PER_CLAIM, + presence_ttl: float = PRESENCE_TTL_SECONDS, + claim_ttl: float = CLAIM_TTL_SECONDS, + clock: Any = time.time, +) -> list[dict[str, Any]]: + """Refresh room presence and lease pending commands to one Desktop.""" + + consumer_id = _identifier(consumer_id, label="consumer_id") + authorities = _room_authorities(room_authorities) + requested_actions = ( + {str(action or "").strip().casefold() for action in actions} + if isinstance(actions, (list, tuple, set, frozenset)) + else set() + ) + if requested_actions and not requested_actions.issubset(_ACTIONS): + raise DesktopRoomMailboxError("invalid action filter") + limit = max(1, min(MAX_COMMANDS_PER_CLAIM, int(limit))) + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + _expire_stale_state(conn, now=now) + owned = _owned_rooms( + conn, + consumer_id=consumer_id, + authorities=authorities, + now=now, + presence_ttl=presence_ttl, + ) + if not owned: + return [] + placeholders = ",".join("?" for _ in owned) + action_sql = "" + action_params: tuple[str, ...] = () + if requested_actions: + action_placeholders = ",".join("?" for _ in requested_actions) + action_sql = f" AND command.action IN ({action_placeholders})" + action_params = tuple(sorted(requested_actions)) + rows = conn.execute( + f"""SELECT command.* FROM desktop_room_commands AS command + WHERE command.room_id IN ({placeholders}) + {action_sql} + AND ( + command.state = 'pending' + OR (command.state = 'claimed' AND COALESCE(command.lease_expires_at, 0) <= ?) + ) + ORDER BY CASE command.action WHEN 'stop' THEN 0 ELSE 1 END, + command.created_at, command.command_id + LIMIT ?""", + (*owned, *action_params, now, limit), + ).fetchall() + claimed: list[dict[str, Any]] = [] + for row in rows: + lease_token = secrets.token_hex(16) + updated = conn.execute( + """UPDATE desktop_room_commands + SET state = 'claimed', lease_owner = ?, lease_token = ?, + lease_expires_at = ?, attempts = attempts + 1, + updated_at = ? + WHERE command_id = ? + AND ( + state = 'pending' + OR (state = 'claimed' AND COALESCE(lease_expires_at, 0) <= ?) + )""", + ( + consumer_id, + lease_token, + now + float(claim_ttl), + now, + str(row["command_id"]), + now, + ), + ) + if updated.rowcount != 1: + continue + current = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (str(row["command_id"]),), + ).fetchone() + command = _command(current) + if str(current["action"]) == "stop": + target_command_id = str( + command.get("payload", {}).get("target_command_id") or "" + ) + if target_command_id: + target = conn.execute( + "SELECT state, result_json FROM desktop_room_commands WHERE command_id = ?", + (target_command_id,), + ).fetchone() + if target is not None: + command["target_command_state"] = str(target["state"]) + target_result = ( + json.loads(target["result_json"]) + if target["result_json"] + else {} + ) + if isinstance(target_result, dict) and target_result.get("code"): + command["target_result_code"] = str(target_result["code"]) + claimed.append(command) + return claimed + + +def refresh_presence( + db_path: Path | str, + *, + consumer_id: str, + room_authorities: Any, + presence_ttl: float = PRESENCE_TTL_SECONDS, + clock: Any = time.time, +) -> list[str]: + """Renew classic Group Chat ownership without leasing queued commands.""" + + consumer_id = _identifier(consumer_id, label="consumer_id") + authorities = _room_authorities(room_authorities) + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + _expire_stale_state(conn, now=now) + return _owned_rooms( + conn, + consumer_id=consumer_id, + authorities=authorities, + now=now, + presence_ttl=presence_ttl, + ) + + +def complete_command( + db_path: Path | str, + *, + consumer_id: str, + command_id: str, + lease_token: str, + success: bool, + result: Any, + clock: Any = time.time, +) -> dict[str, Any]: + """Commit one claimed command result, tolerating an ACK retry.""" + + consumer_id = _identifier(consumer_id, label="consumer_id") + command_id = _identifier(command_id, label="command_id") + lease_token = _identifier(lease_token, label="lease_token") + encoded = _payload_json(result) + state = "completed" if success else "failed" + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + row = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (command_id,), + ).fetchone() + if row is None: + raise DesktopRoomMailboxError("command not found") + if str(row["state"]) in _TERMINAL_STATES: + if str(row["state"]) == state and str(row["result_json"] or "") == encoded: + return _command(row, idempotent=True) + raise DesktopRoomMailboxError("command already has a different result") + if ( + str(row["state"]) != "claimed" + or str(row["lease_owner"] or "") != consumer_id + or str(row["lease_token"] or "") != lease_token + or float(row["lease_expires_at"] or 0) <= now + ): + raise DesktopRoomMailboxError( + "command lease is no longer owned by this Desktop" + ) + updated = conn.execute( + """UPDATE desktop_room_commands + SET state = ?, result_json = ?, lease_owner = NULL, + lease_token = NULL, lease_expires_at = NULL, updated_at = ? + WHERE command_id = ? AND state = 'claimed' AND lease_owner = ? + AND lease_token = ? AND lease_expires_at > ?""", + (state, encoded, now, command_id, consumer_id, lease_token, now), + ) + if updated.rowcount != 1: + raise DesktopRoomMailboxError("command completion raced another consumer") + current = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (command_id,), + ).fetchone() + return _command(current) + + +def renew_command( + db_path: Path | str, + *, + consumer_id: str, + command_id: str, + lease_token: str, + claim_ttl: float = CLAIM_TTL_SECONDS, + presence_ttl: float = PRESENCE_TTL_SECONDS, + clock: Any = time.time, +) -> dict[str, Any]: + """Extend one live claim without allowing an expired attempt to revive.""" + + consumer_id = _identifier(consumer_id, label="consumer_id") + command_id = _identifier(command_id, label="command_id") + lease_token = _identifier(lease_token, label="lease_token") + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + row = conn.execute( + """SELECT room_id FROM desktop_room_commands + WHERE command_id = ? AND state = 'claimed' + AND lease_owner = ? AND lease_token = ? + AND lease_expires_at > ?""", + (command_id, consumer_id, lease_token, now), + ).fetchone() + if row is None: + raise DesktopRoomMailboxError( + "command lease is no longer owned by this Desktop" + ) + room_id = str(row["room_id"]) + owner = conn.execute( + """UPDATE desktop_room_owners + SET expires_at = ? + WHERE room_id = ? AND consumer_id = ? AND expires_at > ?""", + (now + float(presence_ttl), room_id, consumer_id, now), + ) + if owner.rowcount != 1: + raise DesktopRoomMailboxError( + "room authority is no longer owned by this Desktop" + ) + updated = conn.execute( + """UPDATE desktop_room_commands + SET lease_expires_at = ?, updated_at = ? + WHERE command_id = ? AND state = 'claimed' + AND lease_owner = ? AND lease_token = ? + AND lease_expires_at > ?""", + ( + now + float(claim_ttl), + now, + command_id, + consumer_id, + lease_token, + now, + ), + ) + if updated.rowcount != 1: + raise DesktopRoomMailboxError( + "command lease is no longer owned by this Desktop" + ) + conn.execute( + """INSERT INTO desktop_room_presence ( + consumer_id, room_id, expires_at + ) VALUES (?, ?, ?) + ON CONFLICT(consumer_id, room_id) DO UPDATE + SET expires_at = excluded.expires_at""", + (consumer_id, room_id, now + float(presence_ttl)), + ) + current = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (command_id,), + ).fetchone() + return _command(current) + + +def retry_failed_command( + db_path: Path | str, + *, + room_id: Any, + command_id: Any = None, + clock: Any = time.time, +) -> dict[str, Any]: + """Requeue one exact failed Desktop command after explicit owner action.""" + + room_id = _room_identifier(room_id) + requested_id = str(command_id or "").strip() + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + if requested_id: + row = conn.execute( + """SELECT * FROM desktop_room_commands + WHERE room_id = ? AND command_id = ?""", + (room_id, _identifier(requested_id, label="command_id")), + ).fetchone() + else: + row = conn.execute( + """SELECT * FROM desktop_room_commands + WHERE room_id = ? AND state IN ('failed', 'pending') + ORDER BY updated_at DESC, command_id DESC LIMIT 1""", + (room_id,), + ).fetchone() + if row is None: + raise DesktopRoomMailboxError("no failed Group Chat command needs retry") + if str(row["state"]) == "pending": + return _command(row, idempotent=True) + if str(row["state"]) != "failed": + raise DesktopRoomMailboxError("that Group Chat command is not retryable") + conn.execute( + """UPDATE desktop_room_commands + SET state='pending', lease_token=NULL, lease_owner=NULL, + lease_expires_at=NULL, result_json=NULL, + created_at=?, updated_at=? + WHERE command_id=? AND state='failed'""", + (now, now, str(row["command_id"])), + ) + retried = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (str(row["command_id"]),), + ).fetchone() + if retried is None: + raise DesktopRoomMailboxError("Group Chat command disappeared during retry") + result = _command(retried) + _notify_pending(db_path) + return result + + +def retry_failed_commands( + db_path: Path | str, + *, + room_id: Any, + command_ids: Any = None, + clock: Any = time.time, +) -> list[dict[str, Any]]: + """Requeue a bounded oldest-first batch after explicit owner confirmation.""" + + room_id = _room_identifier(room_id) + now = float(clock()) + retried: list[dict[str, Any]] = [] + requested = tuple( + _identifier(str(item), label="command_id") + for item in (command_ids or ()) + if str(item).strip() + ) + if len(requested) > MAX_RETRY_COMMANDS: + raise DesktopRoomMailboxError("too many Group Chat commands to retry") + with _transaction(db_path, immediate=True) as conn: + if requested: + placeholders = ",".join("?" for _ in requested) + rows = conn.execute( + f"""SELECT * FROM desktop_room_commands + WHERE room_id=? AND command_id IN ({placeholders}) + AND state IN ('failed','pending') + ORDER BY created_at, command_id""", + (room_id, *requested), + ).fetchall() + if len(rows) != len(requested): + raise DesktopRoomMailboxError( + "Group Chat retry scope changed before it could be applied" + ) + else: + rows = conn.execute( + """SELECT * FROM desktop_room_commands + WHERE room_id=? AND state='failed' + ORDER BY created_at, command_id + LIMIT ?""", + (room_id, MAX_RETRY_COMMANDS), + ).fetchall() + if not rows: + pending = conn.execute( + """SELECT * FROM desktop_room_commands + WHERE room_id=? AND state='pending' + ORDER BY created_at, command_id LIMIT 1""", + (room_id,), + ).fetchone() + if pending is not None: + return [_command(pending, idempotent=True)] + raise DesktopRoomMailboxError("no failed Group Chat command needs retry") + for index, row in enumerate(rows): + command_id = str(row["command_id"]) + if str(row["state"]) == "pending": + retried.append(_command(row, idempotent=True)) + continue + conn.execute( + """UPDATE desktop_room_commands + SET state='pending', lease_token=NULL, lease_owner=NULL, + lease_expires_at=NULL, result_json=NULL, + created_at=?, updated_at=? + WHERE command_id=? AND state='failed'""", + (now + index * 0.000001, now, command_id), + ) + current = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id=?", + (command_id,), + ).fetchone() + if current is None: + raise DesktopRoomMailboxError( + "Group Chat command disappeared during retry" + ) + retried.append(_command(current)) + _notify_pending(db_path) + return retried + + +def retryable_command_ids( + db_path: Path | str, + *, + room_id: Any, +) -> tuple[str, ...]: + """Freeze the bounded oldest-first retry scope before mutating it.""" + + room_id = _room_identifier(room_id) + with _transaction(db_path) as conn: + rows = conn.execute( + """SELECT command_id FROM desktop_room_commands + WHERE room_id=? AND state='failed' + ORDER BY created_at, command_id + LIMIT ?""", + (room_id, MAX_RETRY_COMMANDS), + ).fetchall() + if not rows: + pending = conn.execute( + """SELECT command_id FROM desktop_room_commands + WHERE room_id=? AND state='pending' + ORDER BY created_at, command_id LIMIT 1""", + (room_id,), + ).fetchone() + rows = [pending] if pending is not None else [] + if not rows: + raise DesktopRoomMailboxError("no failed Group Chat command needs retry") + return tuple(str(row["command_id"]) for row in rows) + + +def failed_command_counts( + db_path: Path | str, + room_ids: Any, +) -> dict[str, int]: + """Return bounded failed-command counts for requested Desktop rooms.""" + + rooms = _query_room_ids(room_ids) + if not rooms: + return {} + counts: dict[str, int] = {} + with _transaction(db_path) as conn: + for index in range(0, len(rooms), MAX_ROOM_IDS): + batch = rooms[index : index + MAX_ROOM_IDS] + placeholders = ",".join("?" for _ in batch) + rows = conn.execute( + f"""SELECT room_id, COUNT(*) AS count + FROM desktop_room_commands + WHERE room_id IN ({placeholders}) AND state='failed' + GROUP BY room_id""", + tuple(batch), + ).fetchall() + counts.update( + {str(row["room_id"]): int(row["count"]) for row in rows} + ) + return counts + + +def room_available( + db_path: Path | str, + room_id: str, + *, + clock: Any = time.time, +) -> bool: + """Return whether a connected Desktop currently advertises this room.""" + + room_id = _room_identifier(room_id) + now = float(clock()) + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT 1 FROM desktop_room_presence + WHERE room_id = ? AND expires_at > ? LIMIT 1""", + (room_id, now), + ).fetchone() + return row is not None + + +def available_room_ids( + db_path: Path | str, + room_ids: Any, + *, + clock: Any = time.time, +) -> set[str]: + """Return all advertised room ids using one bounded database read.""" + + rooms = _query_room_ids(room_ids) + if not rooms: + return set() + now = float(clock()) + available: set[str] = set() + with _transaction(db_path) as conn: + for index in range(0, len(rooms), MAX_ROOM_IDS): + batch = rooms[index : index + MAX_ROOM_IDS] + placeholders = ",".join("?" for _ in batch) + rows = conn.execute( + f"""SELECT DISTINCT room_id FROM desktop_room_presence + WHERE room_id IN ({placeholders}) AND expires_at > ?""", + (*batch, now), + ).fetchall() + available.update(str(row["room_id"]) for row in rows) + return available + + +def latest_command_states( + db_path: Path | str, + room_ids: Any, +) -> dict[str, dict[str, Any]]: + """Return the newest command state for each requested room.""" + + rooms = _query_room_ids(room_ids) + if not rooms: + return {} + states: dict[str, dict[str, Any]] = {} + with _transaction(db_path) as conn: + for index in range(0, len(rooms), MAX_ROOM_IDS): + batch = rooms[index : index + MAX_ROOM_IDS] + placeholders = ",".join("?" for _ in batch) + rows = conn.execute( + f"""SELECT c.* FROM desktop_room_commands AS c + WHERE c.room_id IN ({placeholders}) + AND c.command_id = ( + SELECT newer.command_id + FROM desktop_room_commands AS newer + WHERE newer.room_id = c.room_id + ORDER BY newer.created_at DESC, newer.command_id DESC + LIMIT 1 + )""", + tuple(batch), + ).fetchall() + states.update({str(row["room_id"]): _command(row) for row in rows}) + return states diff --git a/gateway/hosted_room_contract.py b/gateway/hosted_room_contract.py new file mode 100644 index 0000000000000..2c053f476a74f --- /dev/null +++ b/gateway/hosted_room_contract.py @@ -0,0 +1,385 @@ +"""Validation and public error contract for gateway-hosted Group Chats.""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path +from typing import Any, Mapping + + +PROTOCOL_VERSION = 2 +MAX_ROOM_ID_CHARS = 128 +MAX_EVENT_ID_CHARS = 128 +MAX_ROOM_NAME_CHARS = 200 +MAX_EVENT_KIND_CHARS = 64 +MAX_ACTOR_ID_CHARS = 128 +MAX_ACTOR_LABEL_CHARS = 200 +MAX_MEMBERS = 128 +MAX_MEMBERS_JSON_BYTES = 128 * 1024 +MAX_EVENT_JSON_BYTES = 256 * 1024 +MAX_LOG_LIMIT = 500 +MAX_LOG_PAGE_BYTES = 2 * 1024 * 1024 +MAX_ROOM_LIST_LIMIT = 500 +MAX_ACTIVE_ROOMS = 256 +MAX_DISBANDED_ROOM_TOMBSTONES = 512 +DISBANDED_ROOM_RETENTION_SECONDS = 90 * 24 * 60 * 60 +DISBANDED_REPLICA_RETENTION_SECONDS = 90 * 24 * 60 * 60 +MAX_EVENTS_PER_ROOM = 50_000 +MAX_ROOM_EVENT_BYTES = 256 * 1024 * 1024 +# Leave substantial headroom below the pre-update state.db snapshot ceiling. +# Event accounting does not include SQLite indexes or repeated room ids, so the +# logical budget must stay well below the physical-file limit. +MAX_GATEWAY_EVENT_BYTES = 16 * 1024 * 1024 +CONTROL_EVENT_COUNT_RESERVE = 64 +CONTROL_EVENT_BYTE_RESERVE = 1024 * 1024 +_JOURNAL_MODE_LOCK_RETRIES = 8 + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") +_EVENT_KIND_RE = re.compile(r"^[a-z][a-z0-9_.-]*$") +_ROOM_SCHEMA_COLUMNS = frozenset({ + "room_id", + "name", + "members_json", + "authority_gateway_id", + "authority_epoch", + "next_seq", + "event_bytes", + "revision", + "created_at", + "updated_at", + "disbanded_at", +}) +_EVENT_SCHEMA_COLUMNS = frozenset({ + "room_id", + "seq", + "event_id", + "kind", + "actor_json", + "authority_epoch", + "payload_json", + "created_at", +}) +_RETIRED_ROOM_SCHEMA_COLUMNS = frozenset({"room_id", "retired_at"}) +_LINK_SCHEMA_COLUMNS = frozenset({ + "room_id", + "member_id", + "target_url", + "target_profile", + "grant", + "catalog_json", + "cancellation_scope_id", + "trace_id", + "transport_security", + "status", + "updated_at", +}) +_REMOTE_RUN_SCHEMA_COLUMNS = frozenset({ + "room_id", + "home_install_id", + "authority_gateway_id", + "authority_epoch", + "member_id", + "task_id", + "execution_generation", + "target_install_id", + "target_profile", + "run_id", + "session_id", + "created_at", + "updated_at", +}) +_REMOTE_RUN_IDENTITY_COLUMNS = ( + "room_id", + "home_install_id", + "authority_gateway_id", + "authority_epoch", + "member_id", + "target_install_id", + "target_profile", + "task_id", + "execution_generation", +) +_REVOKED_GRANT_SCHEMA_COLUMNS = frozenset({ + "scope_key", + "expires_at", + "revoked_before", +}) +_REVOKED_GRANT_ID_SCHEMA_COLUMNS = frozenset({"scope_key", "grant_id", "expires_at"}) +_PEER_RESERVATION_SCHEMA_COLUMNS = frozenset({ + "room_id", + "member_id", + "target_profile", + "authority_gateway_id", + "authority_epoch", + "expires_at", + "revoked_at", + "created_at", + "updated_at", +}) +_QUARANTINE_SCHEMA_COLUMNS = frozenset({"room_id", "reason", "detected_at"}) +_ROOM_RESERVATION_SCHEMA_COLUMNS = frozenset({ + "room_id", + "owner_kind", + "reserved_at", +}) +_REPLICA_RESERVATION_COLUMNS = frozenset({ + "room_id", + "created_at", +}) +_ROOM_SAFETY_TRIGGERS = frozenset({ + "trg_hosted_rooms_reject_reserved_insert", + "trg_hosted_rooms_reserve_insert", + "trg_hosted_replicas_reject_reserved_insert", + "trg_hosted_replicas_reserve_insert", + "trg_hosted_events_reject_quarantined_insert", + "trg_hosted_events_quarantine_unsafe_lineage", +}) + +_EVENT_KINDS_BY_ACTOR = { + "user": frozenset({"message.user"}), + "member": frozenset({"message.member"}), + "gateway": frozenset({ + "member.unavailable", + "room.activity", + "room.stop_requested", + "turn.deferred", + "turn.reassigned", + "turn.cancelled", + "turn.failed", + "turn.settled", + "turn.started", + }), + "system": frozenset({ + "authority.claimed", + "authority.lost", + "room.created", + "room.disbanded", + "room.members_changed", + "room.renamed", + }), +} +_ACTOR_FIELDS = frozenset({"kind", "id", "display_name", "profile", "connection_id"}) + + +class HostedRoomError(ValueError): + """Base class for invalid or conflicting hosted-room operations.""" + + +class RoomNotFoundError(HostedRoomError): + """Raised when a room does not exist or has been disbanded.""" + + +class RoomHistoryExpiredError(RoomNotFoundError): + """Raised when a retired room remains reserved after history compaction.""" + + reason = "room_history_expired" + + +class RoomConflictError(HostedRoomError): + """Raised when an idempotency key is reused for different room state.""" + + +class RoomProbeUnavailableError(HostedRoomError): + """Raised when a non-blocking ownership probe cannot read the room store.""" + + +class EventConflictError(HostedRoomError): + """Raised when an event id is reused with different immutable content.""" + + +class AuthorityConflictError(HostedRoomError): + """Raised when a stale room authority attempts to mutate hosted state.""" + + reason = "authority_conflict" + + +class AuthoritySupersededError(AuthorityConflictError): + """Raised when a successful authority claim was later superseded.""" + + +class RoomQuarantinedError(AuthorityConflictError): + """Raised when an unsafe legacy takeover must remain read-only.""" + + reason = "room_authority_quarantined" + + +def _public_limits(): + """Resolve re-exported limits late to preserve the original public seam.""" + from gateway import hosted_rooms + + return hosted_rooms + + +def default_db_path() -> Path: + """Return the gateway-wide state database for the active install.""" + from hermes_constants import get_hermes_home + + home = get_hermes_home() + root = home.parent.parent if home.parent.name == "profiles" else home + return root / "state.db" + + +def local_authority_gateway_id() -> str: + """Return the stable server-owned identity for hosted-room authority.""" + from hermes_cli.install_identity import get_install_id + + install_id = get_install_id() + if not install_id: + raise HostedRoomError("stable gateway install identity is unavailable") + return _validate_identifier( + f"install:{install_id}", + label="authority_gateway_id", + max_chars=_public_limits().MAX_ACTOR_ID_CHARS, + ) + + +def _canonical_json(value: Any, *, label: str, max_bytes: int) -> str: + try: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + except (TypeError, ValueError, RecursionError) as exc: + raise HostedRoomError(f"{label} must be JSON-serializable") from exc + if len(encoded.encode("utf-8")) > max_bytes: + raise HostedRoomError(f"{label} is too large") + return encoded + + +def _validate_identifier(value: Any, *, label: str, max_chars: int) -> str: + if not isinstance(value, str): + raise HostedRoomError(f"{label} must be a string") + value = value.strip() + if not value or len(value) > max_chars or not _IDENTIFIER_RE.fullmatch(value): + raise HostedRoomError(f"invalid {label}") + return value + + +def user_event_id(client_event_id: Any) -> str: + """Map a client retry key into the server-owned user-event namespace.""" + normalized = _validate_identifier( + client_event_id, + label="event_id", + max_chars=_public_limits().MAX_EVENT_ID_CHARS, + ) + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + return f"user:{digest}" + + +def _validate_room_name(value: Any) -> str: + if not isinstance(value, str): + raise HostedRoomError("name must be a string") + value = value.strip() + if not value or len(value) > _public_limits().MAX_ROOM_NAME_CHARS: + raise HostedRoomError("invalid room name") + return value + + +def _validate_members(value: Any) -> tuple[list[dict[str, Any]], str]: + if not isinstance(value, list): + raise HostedRoomError("members must be a list") + limits = _public_limits() + if len(value) > limits.MAX_MEMBERS: + raise HostedRoomError("too many room members") + members: list[dict[str, Any]] = [] + for member in value: + if not isinstance(member, dict): + raise HostedRoomError("each room member must be an object") + members.append(dict(member)) + encoded = _canonical_json( + members, + label="members", + max_bytes=limits.MAX_MEMBERS_JSON_BYTES, + ) + return members, encoded + + +def _legacy_members_match( + existing_json: str, + proposed: list[dict[str, Any]], +) -> bool: + """Allow adoption to add routing metadata an older room could not store.""" + + try: + existing = json.loads(existing_json) + except (TypeError, ValueError): + return False + if not isinstance(existing, list) or len(existing) != len(proposed): + return False + for previous, current in zip(existing, proposed, strict=True): + if not isinstance(previous, dict): + return False + previous = dict(previous) + current = dict(current) + previous_target = previous.pop("target", None) + current_target = current.pop("target", None) + if previous != current: + return False + if previous_target not in (None, {}) and previous_target != current_target: + return False + return True + + +def _validate_event_kind(value: Any) -> str: + if not isinstance(value, str): + raise HostedRoomError("kind must be a string") + value = value.strip() + if ( + not value + or len(value) > _public_limits().MAX_EVENT_KIND_CHARS + or not _EVENT_KIND_RE.fullmatch(value) + ): + raise HostedRoomError("invalid event kind") + return value + + +def _optional_actor_field(actor: dict[str, Any], field: str, max_chars: int) -> str: + value = actor.get(field) + if value is None: + return "" + if not isinstance(value, str): + raise HostedRoomError(f"actor.{field} must be a string") + value = value.strip() + if len(value) > max_chars: + raise HostedRoomError(f"actor.{field} is too long") + return value + + +def _validate_actor(value: Any, *, kind: str) -> tuple[dict[str, str], str]: + if not isinstance(value, dict): + raise HostedRoomError("actor must be an object") + unknown = set(value) - _ACTOR_FIELDS + if unknown: + raise HostedRoomError(f"unknown actor fields: {', '.join(sorted(unknown))}") + + actor_kind = value.get("kind") + if not isinstance(actor_kind, str) or actor_kind not in _EVENT_KINDS_BY_ACTOR: + raise HostedRoomError("invalid actor.kind") + if kind not in _EVENT_KINDS_BY_ACTOR[actor_kind]: + raise HostedRoomError(f"actor kind '{actor_kind}' cannot append '{kind}'") + + limits = _public_limits() + actor_id = _validate_identifier( + value.get("id"), + label="actor.id", + max_chars=limits.MAX_ACTOR_ID_CHARS, + ) + actor = {"kind": actor_kind, "id": actor_id} + for field, max_chars in ( + ("display_name", limits.MAX_ACTOR_LABEL_CHARS), + ("profile", limits.MAX_ACTOR_ID_CHARS), + ("connection_id", limits.MAX_ACTOR_ID_CHARS), + ): + field_value = _optional_actor_field(value, field, max_chars) + if field_value: + actor[field] = field_value + encoded = _canonical_json( + actor, + label="actor", + max_bytes=4 * 1024, + ) + return actor, encoded diff --git a/gateway/hosted_room_control_client.py b/gateway/hosted_room_control_client.py new file mode 100644 index 0000000000000..929db7ea82e54 --- /dev/null +++ b/gateway/hosted_room_control_client.py @@ -0,0 +1,170 @@ +"""Credential-safe client for a room authority's reciprocal control API.""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from gateway.hosted_room_controls import StoredPeerRoomControl +from hermes_cli.urllib_security import open_credentialed_url + + +MAX_CONTROL_RESPONSE_BYTES = 1024 * 1024 + + +class RoomControlClientError(RuntimeError): + def __init__( + self, + message: str, + *, + status_code: int | None = None, + retryable: bool = False, + user_message: str = "", + ) -> None: + super().__init__(message) + self.status_code = status_code + self.retryable = retryable + self.user_message = user_message + + +class RoomControlHTTPClient: + def __init__( + self, + link: StoredPeerRoomControl, + *, + timeout_seconds: float = 15.0, + ) -> None: + self.link = link + self.timeout_seconds = float(timeout_seconds) + + def _request( + self, + *, + method: str, + body: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + room_id = urllib.parse.quote(self.link.room_id, safe="") + request = urllib.request.Request( + f"{self.link.home_url.rstrip('/')}/v1/room-controls/{room_id}", + data=( + json.dumps(body, ensure_ascii=True, separators=(",", ":")).encode( + "utf-8" + ) + if body is not None + else None + ), + method=method, + headers={ + "Authorization": f"HermesRoomControl {self.link.control_token}", + "X-Hermes-Room-Member": self.link.member_id, + "Content-Type": "application/json", + "User-Agent": "Hermes-RoomControl/1.0", + }, + ) + try: + with open_credentialed_url( + request, + timeout=self.timeout_seconds, + ) as response: + raw = response.read(MAX_CONTROL_RESPONSE_BYTES + 1) + if len(raw) > MAX_CONTROL_RESPONSE_BYTES: + raise RoomControlClientError( + "Group Chat control response exceeded the size limit" + ) + except urllib.error.HTTPError as exc: + try: + detail = exc.read(500).decode("utf-8", "replace") + except Exception: + detail = "" + user_message = "" + try: + payload = json.loads(detail) + raw_error = payload.get("error") if isinstance(payload, dict) else None + if isinstance(raw_error, dict): + candidate = str(raw_error.get("message") or "").strip() + if candidate and len(candidate) <= 300: + user_message = candidate + except (TypeError, ValueError): + pass + raise RoomControlClientError( + f"Group Chat host rejected control with HTTP {exc.code}", + status_code=exc.code, + retryable=exc.code in {408, 425, 429} or exc.code >= 500, + user_message=user_message, + ) from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise RoomControlClientError( + "Group Chat host is unreachable", + retryable=True, + ) from exc + try: + payload = json.loads(raw.decode("utf-8", "replace")) + except (UnicodeError, ValueError) as exc: + raise RoomControlClientError( + "Group Chat host returned invalid control data" + ) from exc + if not isinstance(payload, dict): + raise RoomControlClientError( + "Group Chat host returned a non-object control response" + ) + return payload + + def summary(self) -> dict[str, Any]: + return self._request(method="GET") + + def revoke(self) -> None: + self._request(method="DELETE") + + def mutate( + self, + *, + action: str, + command_id: str, + text: str = "", + actor_display_name: str = "Messaging", + ) -> dict[str, Any]: + return self._request( + method="POST", + body={ + "action": action, + "command_id": command_id, + **({"text": text} if text else {}), + **( + {"actor_display_name": actor_display_name} + if actor_display_name + else {} + ), + }, + ) + + +def revoke_stored_peer_control( + db_path: Path | str, + *, + room_id: str, + member_id: str, +) -> int: + """Revoke the authority credential, then erase peer bearer material.""" + + from gateway import hosted_room_controls + + link = next( + ( + candidate + for candidate in hosted_room_controls.load_peer_control_links( + db_path, include_inactive=True + ).links + if candidate.room_id == room_id and candidate.member_id == member_id + ), + None, + ) + if link is not None and link.status == "active": + RoomControlHTTPClient(link).revoke() + return hosted_room_controls.delete_peer_control_links( + db_path, room_id=room_id, member_id=member_id + ) diff --git a/gateway/hosted_room_controls.py b/gateway/hosted_room_controls.py new file mode 100644 index 0000000000000..a8f2891182f14 --- /dev/null +++ b/gateway/hosted_room_controls.py @@ -0,0 +1,1325 @@ +"""Private credentials for reciprocal hosted Group Chat control. + +The room's home gateway stores only a SHA-256 commitment for each scoped +control credential. A participating peer stores the corresponding bearer +credential and validated home endpoint in the gateway-wide ``state.db``. +Credentials are deliberately absent from reprs, status mappings, and errors. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import math +import os +import re +import secrets +import sqlite3 +import time +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterator + +from gateway.hosted_room_peer import ( + HostedRoomPeerError, + gateway_room_grant_secret, + validate_room_link_url, +) + + +TOKEN_BYTES = 32 +MAX_TOKEN_CHARS = 512 +MAX_PEER_LINKS = 1024 +MAX_LOAD_LINKS = MAX_PEER_LINKS +MAX_ROOM_NAME_CHARS = 200 +MAX_ROOM_MEMBERS = 64 +MAX_CONTROL_COMMANDS = 4096 +ROOM_LIFETIME_EXPIRES_AT = 253_402_300_799.0 +_JOURNAL_MODE_LOCK_RETRIES = 5 + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") +_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]+$") +_PEER_STATUSES = frozenset({"active", "expired", "revoked", "quarantined"}) +_DUMMY_DIGEST = hashlib.sha256(b"invalid-hosted-room-control-token").digest() + + +def control_retry_attempt_id(command_id: Any, task_id: Any) -> str: + """Return one stable, bounded Retry identity across direct/worker paths.""" + + command = str(command_id or "") + if command.startswith("worker:"): + command = command[len("worker:") :] + material = f"{command}|{str(task_id or '')}".encode("utf-8") + return f"room-retry:{hashlib.sha256(material).hexdigest()}" + + +class HostedRoomControlError(ValueError): + """Raised when a reciprocal room-control record is invalid or conflicts.""" + + +class HostedRoomControlConflictError(HostedRoomControlError): + """Raised when immutable control-link identity changes unexpectedly.""" + + +@dataclass(frozen=True) +class IssuedRoomControlToken: + """One-time credential returned by a room's authority gateway.""" + + room_id: str + member_id: str + authority_gateway_id: str + authority_epoch: int + control_token: str = field(repr=False) + status: str + created_at: float + expires_at: float + + def as_status(self) -> dict[str, Any]: + return { + "room_id": self.room_id, + "member_id": self.member_id, + "authority_gateway_id": self.authority_gateway_id, + "authority_epoch": self.authority_epoch, + "status": self.status, + "created_at": self.created_at, + "expires_at": self.expires_at, + } + + +@dataclass(frozen=True) +class StoredPeerRoomControl: + """Private peer-side route back to a room's authority gateway.""" + + room_id: str + member_id: str + home_url: str + transport_security: str + authority_gateway_id: str + authority_epoch: int + room_name: str + member_count: int + control_token: str = field(repr=False) + status: str + created_at: float + updated_at: float + expires_at: float + revoked_at: float | None = None + + def as_status(self) -> dict[str, Any]: + return { + "room_id": self.room_id, + "member_id": self.member_id, + "home_url": self.home_url, + "transport_security": self.transport_security, + "authority_gateway_id": self.authority_gateway_id, + "authority_epoch": self.authority_epoch, + "room_name": self.room_name, + "member_count": self.member_count, + "status": self.status, + "created_at": self.created_at, + "updated_at": self.updated_at, + "expires_at": self.expires_at, + **({"revoked_at": self.revoked_at} if self.revoked_at is not None else {}), + } + + +@dataclass(frozen=True) +class PeerRoomControlSave: + link: StoredPeerRoomControl + idempotent: bool + + +@dataclass(frozen=True) +class PeerRoomControlLoad: + links: tuple[StoredPeerRoomControl, ...] + quarantined: int + truncated: bool + + +@dataclass(frozen=True) +class RoomControlCommandPlan: + task_ids: tuple[str, ...] + result: dict[str, Any] | None + idempotent: bool + + +@dataclass(frozen=True) +class PendingRoomControlRetry: + command_id: str + room_id: str + member_id: str + task_ids: tuple[str, ...] + + +def default_db_path() -> Path: + """Use the same gateway-wide state database as hosted room authority.""" + + from gateway.hosted_rooms import default_db_path as hosted_room_db_path + + return hosted_room_db_path() + + +def _identifier(value: Any, *, label: str) -> str: + if not isinstance(value, str): + raise HostedRoomControlError(f"invalid {label}") + normalized = value.strip() + if not _IDENTIFIER_RE.fullmatch(normalized): + raise HostedRoomControlError(f"invalid {label}") + return normalized + + +def _authority_epoch(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise HostedRoomControlError("invalid authority_epoch") + return value + + +def _timestamp(value: Any, *, label: str) -> float: + if isinstance(value, bool): + raise HostedRoomControlError(f"invalid {label}") + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise HostedRoomControlError(f"invalid {label}") from exc + if not math.isfinite(parsed) or parsed <= 0: + raise HostedRoomControlError(f"invalid {label}") + return parsed + + +def _room_name(value: Any) -> str: + normalized = re.sub(r"\s+", " ", str(value or "")).strip() + if not normalized or len(normalized) > MAX_ROOM_NAME_CHARS: + raise HostedRoomControlError("invalid room_name") + return normalized + + +def _member_count(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise HostedRoomControlError("invalid member_count") + if not 1 <= value <= MAX_ROOM_MEMBERS: + raise HostedRoomControlError("invalid member_count") + return value + + +def _normalize_home_url(value: Any) -> tuple[str, str]: + try: + return validate_room_link_url(value) + except HostedRoomPeerError as exc: + raise HostedRoomControlError("invalid home control endpoint") from exc + + +def _token_is_strong(value: Any) -> bool: + if ( + not isinstance(value, str) + or not value + or len(value) > MAX_TOKEN_CHARS + or not _TOKEN_RE.fullmatch(value) + ): + return False + try: + padding = "=" * (-len(value) % 4) + material = base64.urlsafe_b64decode(value + padding) + except (ValueError, TypeError): + return False + return len(material) >= TOKEN_BYTES + + +def _control_token(value: Any) -> str: + if not _token_is_strong(value): + raise HostedRoomControlError("invalid control credential") + return value + + +def _derived_control_token( + *, + room_id: str, + member_id: str, + authority_gateway_id: str, + authority_epoch: int, + request_id: str, +) -> str: + material = "\0".join( + ( + "hermes-room-control-v1", + room_id, + member_id, + authority_gateway_id, + str(authority_epoch), + request_id, + ) + ).encode("utf-8") + digest = hmac.new(gateway_room_grant_secret(), material, hashlib.sha256).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def _secure_db_file(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + descriptor = os.open(path, os.O_CREAT | os.O_RDWR, 0o600) + os.close(descriptor) + if os.name == "posix": + try: + path.chmod(0o600) + except OSError: + pass + + +def _initialize_schema(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_control_tokens ( + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + request_id TEXT NOT NULL, + token_hash BLOB NOT NULL CHECK (length(token_hash) = 32), + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + expires_at REAL NOT NULL, + revoked_at REAL, + PRIMARY KEY ( + room_id, member_id, authority_gateway_id, authority_epoch + ) + )""" + ) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_hosted_room_control_tokens_room + ON hosted_room_control_tokens(room_id, status, expires_at)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_peer_controls ( + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + room_name TEXT NOT NULL, + member_count INTEGER NOT NULL CHECK (member_count BETWEEN 1 AND 64), + home_url TEXT NOT NULL, + transport_security TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + control_token TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('active', 'expired', 'revoked', 'quarantined') + ), + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + expires_at REAL NOT NULL, + revoked_at REAL, + quarantine_reason TEXT, + PRIMARY KEY (room_id, member_id) + )""" + ) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_hosted_room_peer_controls_status + ON hosted_room_peer_controls(status, expires_at, updated_at)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_control_commands ( + command_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + action TEXT NOT NULL, + task_ids_json TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + result_json TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + + +def _schema_is_current(conn: sqlite3.Connection) -> bool: + home = { + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_control_tokens)") + } + peer = { + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_peer_controls)") + } + commands = { + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_control_commands)") + } + return { + "room_id", + "member_id", + "authority_gateway_id", + "authority_epoch", + "request_id", + "token_hash", + "status", + "created_at", + "updated_at", + "expires_at", + "revoked_at", + }.issubset(home) and { + "room_id", + "member_id", + "room_name", + "member_count", + "home_url", + "transport_security", + "authority_gateway_id", + "authority_epoch", + "control_token", + "status", + "created_at", + "updated_at", + "expires_at", + "revoked_at", + "quarantine_reason", + }.issubset(peer) and { + "command_id", + "room_id", + "member_id", + "action", + "task_ids_json", + "state", + "result_json", + "created_at", + "updated_at", + }.issubset(commands) + + +def _migrate_schema(conn: sqlite3.Connection) -> None: + home = { + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_control_tokens)") + } + if home and "request_id" not in home: + conn.execute( + """ALTER TABLE hosted_room_control_tokens + ADD COLUMN request_id TEXT NOT NULL DEFAULT 'legacy'""" + ) + + +def _connect(db_path: Path | str) -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + + path = Path(db_path) + _secure_db_file(path) + conn = sqlite3.connect(path, timeout=10) + conn.row_factory = sqlite3.Row + try: + for attempt in range(_JOURNAL_MODE_LOCK_RETRIES): + try: + apply_wal_with_fallback( + conn, db_label="state.db (hosted room controls)" + ) + break + except sqlite3.OperationalError as exc: + if ( + str(exc).lower() != "database is locked" + or attempt + 1 == _JOURNAL_MODE_LOCK_RETRIES + ): + raise + time.sleep(0.01 * (2**attempt)) + conn.execute("PRAGMA secure_delete=ON") + if not _schema_is_current(conn): + conn.execute("BEGIN IMMEDIATE") + _migrate_schema(conn) + _initialize_schema(conn) + if not _schema_is_current(conn): + raise HostedRoomControlError( + "hosted room control schema is incompatible" + ) + conn.commit() + except Exception: + conn.rollback() + conn.close() + raise + _secure_db_file(path) + return conn + + +@contextmanager +def _transaction( + db_path: Path | str, *, immediate: bool = False +) -> Iterator[sqlite3.Connection]: + conn = _connect(db_path) + try: + if immediate: + conn.execute("BEGIN IMMEDIATE") + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _active_room_scope( + conn: sqlite3.Connection, + *, + room_id: str, + authority_gateway_id: str, + authority_epoch: int, + member_id: str | None = None, +) -> bool: + table = conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_rooms'""" + ).fetchone() + if table is None: + return False + row = conn.execute( + """SELECT members_json FROM hosted_rooms + WHERE room_id=? AND authority_gateway_id=? + AND authority_epoch=? AND disbanded_at IS NULL""", + (room_id, authority_gateway_id, authority_epoch), + ).fetchone() + if row is None: + return False + if member_id is None: + return True + try: + members = json.loads(str(row["members_json"])) + except Exception: + return False + return any( + isinstance(member, dict) + and str(member.get("member_id") or member.get("profile") or "") == member_id + for member in members + ) + + +def issue_home_control_token( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + authority_gateway_id: Any, + authority_epoch: Any, + expires_at: Any, + request_id: Any | None = None, + now: float | None = None, +) -> IssuedRoomControlToken: + """Create one replay-safe member credential and retain only its hash.""" + + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + authority_gateway_id = _identifier( + authority_gateway_id, label="authority_gateway_id" + ) + authority_epoch = _authority_epoch(authority_epoch) + normalized_request_id = ( + _identifier(request_id, label="request_id") + if request_id is not None + else f"one-shot:{secrets.token_hex(16)}" + ) + created_at = _timestamp(time.time() if now is None else now, label="now") + expires_at = _timestamp(expires_at, label="expires_at") + if expires_at <= created_at: + raise HostedRoomControlError("control credential expiry must be in the future") + + control_token = ( + _derived_control_token( + room_id=room_id, + member_id=member_id, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + request_id=normalized_request_id, + ) + if request_id is not None + else secrets.token_urlsafe(TOKEN_BYTES) + ) + token_hash = hashlib.sha256(control_token.encode("ascii")).digest() + with _transaction(db_path, immediate=True) as conn: + if not _active_room_scope( + conn, + room_id=room_id, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + member_id=member_id, + ): + raise HostedRoomControlError( + "active Group Chat authority scope is unavailable" + ) + existing = conn.execute( + """SELECT request_id, status, expires_at + FROM hosted_room_control_tokens + WHERE room_id=? AND member_id=? AND authority_gateway_id=? + AND authority_epoch=?""", + (room_id, member_id, authority_gateway_id, authority_epoch), + ).fetchone() + if existing is not None and existing["status"] == "active": + if ( + str(existing["request_id"]) == normalized_request_id + and float(existing["expires_at"]) == expires_at + ): + return IssuedRoomControlToken( + room_id=room_id, + member_id=member_id, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + control_token=control_token, + status="active", + created_at=created_at, + expires_at=expires_at, + ) + if ( + str(existing["request_id"]) != "legacy" + and float(existing["expires_at"]) > created_at + ): + raise HostedRoomControlConflictError( + "an active control credential already exists for this scope" + ) + conn.execute( + """INSERT INTO hosted_room_control_tokens( + room_id, member_id, authority_gateway_id, authority_epoch, + request_id, token_hash, status, created_at, updated_at, expires_at, + revoked_at + ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, NULL) + ON CONFLICT( + room_id, member_id, authority_gateway_id, authority_epoch + ) DO UPDATE SET + token_hash=excluded.token_hash, + request_id=excluded.request_id, + status='active', + created_at=excluded.created_at, + updated_at=excluded.updated_at, + expires_at=excluded.expires_at, + revoked_at=NULL""", + ( + room_id, + member_id, + authority_gateway_id, + authority_epoch, + normalized_request_id, + token_hash, + created_at, + created_at, + expires_at, + ), + ) + return IssuedRoomControlToken( + room_id=room_id, + member_id=member_id, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + control_token=control_token, + status="active", + created_at=created_at, + expires_at=expires_at, + ) + + +def verify_home_control_token( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + authority_gateway_id: Any, + authority_epoch: Any, + control_token: Any, + now: float | None = None, +) -> bool: + """Verify one exact live room/member/authority scope in constant time.""" + + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + authority_gateway_id = _identifier( + authority_gateway_id, label="authority_gateway_id" + ) + authority_epoch = _authority_epoch(authority_epoch) + timestamp = _timestamp(time.time() if now is None else now, label="now") + token_shape_valid = _token_is_strong(control_token) + token_material = ( + control_token.encode("ascii") if token_shape_valid else b"invalid-credential" + ) + candidate_hash = hashlib.sha256(token_material).digest() + + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT token_hash, status, expires_at + FROM hosted_room_control_tokens + WHERE room_id=? AND member_id=? AND authority_gateway_id=? + AND authority_epoch=?""", + (room_id, member_id, authority_gateway_id, authority_epoch), + ).fetchone() + room_active = _active_room_scope( + conn, + room_id=room_id, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + member_id=member_id, + ) + + stored_hash: bytes = _DUMMY_DIGEST + eligible = False + if row is not None: + raw_hash = row["token_hash"] + if isinstance(raw_hash, bytes) and len(raw_hash) == 32: + stored_hash = raw_hash + try: + stored_expiry = float(row["expires_at"]) + except (TypeError, ValueError): + stored_expiry = float("nan") + eligible = bool( + row["status"] == "active" + and math.isfinite(stored_expiry) + and stored_expiry > timestamp + and room_active + and token_shape_valid + ) + digest_matches = hmac.compare_digest(stored_hash, candidate_hash) + return bool(eligible and digest_matches) + + +def revoke_home_control_tokens( + db_path: Path | str, + *, + room_id: Any, + member_id: Any | None = None, + authority_gateway_id: Any | None = None, + authority_epoch: Any | None = None, + now: float | None = None, +) -> int: + """Idempotently revoke matching home-side credential commitments.""" + + clauses = ["room_id=?", "status='active'"] + params: list[Any] = [_identifier(room_id, label="room_id")] + if member_id is not None: + clauses.append("member_id=?") + params.append(_identifier(member_id, label="member_id")) + if authority_gateway_id is not None: + clauses.append("authority_gateway_id=?") + params.append(_identifier(authority_gateway_id, label="authority_gateway_id")) + if authority_epoch is not None: + clauses.append("authority_epoch=?") + params.append(_authority_epoch(authority_epoch)) + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + cursor = conn.execute( + f"""UPDATE hosted_room_control_tokens + SET status='revoked', updated_at=?, revoked_at=? + WHERE {" AND ".join(clauses)}""", + (timestamp, timestamp, *params), + ) + return cursor.rowcount + + +def revoke_home_control_token_value( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + control_token: Any, + now: float | None = None, +) -> int: + """Idempotently revoke the exact bearer, including response-lost retries.""" + + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + token_hash = hashlib.sha256( + _control_token(control_token).encode("ascii") + ).digest() + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + rows = conn.execute( + """SELECT token_hash, status FROM hosted_room_control_tokens + WHERE room_id=? AND member_id=?""", + (room_id, member_id), + ).fetchall() + matched = next( + ( + row + for row in rows + if hmac.compare_digest(bytes(row["token_hash"]), token_hash) + ), + None, + ) + if matched is None: + raise HostedRoomControlError("control credential is invalid") + if str(matched["status"]) == "revoked": + return 0 + changed = conn.execute( + """UPDATE hosted_room_control_tokens + SET status='revoked', updated_at=?, revoked_at=? + WHERE room_id=? AND member_id=? AND token_hash=? + AND status='active'""", + (timestamp, timestamp, room_id, member_id, token_hash), + ) + if changed.rowcount not in {0, 1}: + raise HostedRoomControlError("control credential changed more than once") + return changed.rowcount + + +def _peer_link_from_row(row: sqlite3.Row) -> StoredPeerRoomControl: + room_id = _identifier(row["room_id"], label="room_id") + member_id = _identifier(row["member_id"], label="member_id") + authority_gateway_id = _identifier( + row["authority_gateway_id"], label="authority_gateway_id" + ) + authority_epoch = _authority_epoch(row["authority_epoch"]) + room_name = _room_name(row["room_name"]) + member_count = _member_count(row["member_count"]) + home_url, transport_security = _normalize_home_url(row["home_url"]) + if row["transport_security"] != transport_security: + raise HostedRoomControlError( + "stored control endpoint classification is invalid" + ) + status = str(row["status"] or "") + if status not in _PEER_STATUSES: + raise HostedRoomControlError("stored control status is invalid") + created_at = _timestamp(row["created_at"], label="created_at") + updated_at = _timestamp(row["updated_at"], label="updated_at") + expires_at = _timestamp(row["expires_at"], label="expires_at") + revoked_at = row["revoked_at"] + if revoked_at is not None: + revoked_at = _timestamp(revoked_at, label="revoked_at") + control_token = _control_token(row["control_token"]) + return StoredPeerRoomControl( + room_id=room_id, + member_id=member_id, + home_url=home_url, + transport_security=transport_security, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + room_name=room_name, + member_count=member_count, + control_token=control_token, + status=status, + created_at=created_at, + updated_at=updated_at, + expires_at=expires_at, + revoked_at=revoked_at, + ) + + +def save_peer_control_link( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + home_url: Any, + authority_gateway_id: Any, + authority_epoch: Any, + room_name: Any, + member_count: Any, + control_token: Any, + expires_at: Any, + allow_rotation: bool = False, + now: float | None = None, +) -> PeerRoomControlSave: + """Persist a private peer link, rejecting any immutable identity drift.""" + + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + home_url, transport_security = _normalize_home_url(home_url) + authority_gateway_id = _identifier( + authority_gateway_id, label="authority_gateway_id" + ) + authority_epoch = _authority_epoch(authority_epoch) + room_name = _room_name(room_name) + member_count = _member_count(member_count) + control_token = _control_token(control_token) + timestamp = _timestamp(time.time() if now is None else now, label="now") + expires_at = _timestamp(expires_at, label="expires_at") + if expires_at <= timestamp: + raise HostedRoomControlError("control link expiry must be in the future") + + with _transaction(db_path, immediate=True) as conn: + existing = conn.execute( + """SELECT * FROM hosted_room_peer_controls + WHERE room_id=? AND member_id=?""", + (room_id, member_id), + ).fetchone() + if existing is not None and str(existing["status"]) in {"expired", "revoked"}: + conn.execute( + "DELETE FROM hosted_room_peer_controls WHERE room_id=? AND member_id=?", + (room_id, member_id), + ) + existing = None + if existing is not None: + try: + stored = _peer_link_from_row(existing) + except Exception as exc: + conn.execute( + """UPDATE hosted_room_peer_controls + SET status='quarantined', quarantine_reason='invalid_stored_link', + updated_at=? + WHERE room_id=? AND member_id=?""", + (timestamp, room_id, member_id), + ) + raise HostedRoomControlConflictError( + "stored control link is quarantined" + ) from exc + same_token = hmac.compare_digest(stored.control_token, control_token) + if not ( + stored.home_url == home_url + and stored.authority_gateway_id == authority_gateway_id + and stored.authority_epoch == authority_epoch + ): + raise HostedRoomControlConflictError( + "control link conflicts with stored authority" + ) + rotating = not same_token or stored.expires_at != expires_at + if rotating and allow_rotation is not True: + raise HostedRoomControlConflictError( + "control link conflicts with stored authority" + ) + if ( + rotating + or stored.room_name != room_name + or stored.member_count != member_count + ): + conn.execute( + """UPDATE hosted_room_peer_controls + SET room_name=?, member_count=?, control_token=?, + expires_at=?, status='active', revoked_at=NULL, + quarantine_reason=NULL, updated_at=? + WHERE room_id=? AND member_id=?""", + ( + room_name, + member_count, + control_token, + expires_at, + timestamp, + room_id, + member_id, + ), + ) + stored = _peer_link_from_row( + conn.execute( + """SELECT * FROM hosted_room_peer_controls + WHERE room_id=? AND member_id=?""", + (room_id, member_id), + ).fetchone() + ) + return PeerRoomControlSave(link=stored, idempotent=not rotating) + + count = conn.execute( + "SELECT COUNT(*) FROM hosted_room_peer_controls WHERE status='active'" + ).fetchone()[0] + if int(count) >= MAX_PEER_LINKS: + raise HostedRoomControlError("stored control link limit reached") + conn.execute( + """INSERT INTO hosted_room_peer_controls( + room_id, member_id, room_name, member_count, + home_url, transport_security, + authority_gateway_id, authority_epoch, control_token, + status, created_at, updated_at, expires_at, revoked_at, + quarantine_reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, NULL, NULL)""", + ( + room_id, + member_id, + room_name, + member_count, + home_url, + transport_security, + authority_gateway_id, + authority_epoch, + control_token, + timestamp, + timestamp, + expires_at, + ), + ) + stored = _peer_link_from_row( + conn.execute( + """SELECT * FROM hosted_room_peer_controls + WHERE room_id=? AND member_id=?""", + (room_id, member_id), + ).fetchone() + ) + return PeerRoomControlSave(link=stored, idempotent=False) + + +def load_peer_control_links( + db_path: Path | str, + *, + limit: int = MAX_LOAD_LINKS, + include_inactive: bool = False, + now: float | None = None, +) -> PeerRoomControlLoad: + """Load a bounded page and quarantine malformed private records.""" + + if ( + isinstance(limit, bool) + or not isinstance(limit, int) + or not 1 <= limit <= MAX_LOAD_LINKS + ): + raise HostedRoomControlError(f"limit must be between 1 and {MAX_LOAD_LINKS}") + timestamp = _timestamp(time.time() if now is None else now, label="now") + links: list[StoredPeerRoomControl] = [] + quarantined = 0 + with _transaction(db_path, immediate=True) as conn: + rows = conn.execute( + """SELECT rowid, * FROM hosted_room_peer_controls + ORDER BY updated_at DESC, room_id ASC, member_id ASC + LIMIT ?""", + (limit + 1,), + ).fetchall() + truncated = len(rows) > limit + for row in rows[:limit]: + if row["status"] == "quarantined": + continue + try: + link = _peer_link_from_row(row) + except Exception: + conn.execute( + """UPDATE hosted_room_peer_controls + SET status='quarantined', quarantine_reason='invalid_stored_link', + updated_at=? WHERE rowid=?""", + (timestamp, row["rowid"]), + ) + quarantined += 1 + continue + if link.status == "active" and link.expires_at <= timestamp: + conn.execute( + """UPDATE hosted_room_peer_controls + SET status='expired', updated_at=? WHERE rowid=?""", + (timestamp, row["rowid"]), + ) + link = StoredPeerRoomControl(**{ + **link.__dict__, + "status": "expired", + "updated_at": timestamp, + }) + if include_inactive or link.status == "active": + links.append(link) + return PeerRoomControlLoad( + links=tuple(links), quarantined=quarantined, truncated=truncated + ) + + +def revoke_peer_control_links( + db_path: Path | str, + *, + room_id: Any, + member_id: Any | None = None, + now: float | None = None, +) -> int: + """Idempotently revoke one peer link or every link for a room.""" + + clauses = ["room_id=?", "status NOT IN ('revoked', 'quarantined')"] + params: list[Any] = [_identifier(room_id, label="room_id")] + if member_id is not None: + clauses.append("member_id=?") + params.append(_identifier(member_id, label="member_id")) + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + cursor = conn.execute( + f"""UPDATE hosted_room_peer_controls + SET status='revoked', updated_at=?, revoked_at=? + WHERE {" AND ".join(clauses)}""", + (timestamp, timestamp, *params), + ) + return cursor.rowcount + + +def delete_peer_control_links( + db_path: Path | str, + *, + room_id: Any, + member_id: Any | None = None, +) -> int: + """Erase peer-side bearer material after reciprocal revocation.""" + + clauses = ["room_id=?"] + params: list[Any] = [_identifier(room_id, label="room_id")] + if member_id is not None: + clauses.append("member_id=?") + params.append(_identifier(member_id, label="member_id")) + with _transaction(db_path, immediate=True) as conn: + cursor = conn.execute( + f"DELETE FROM hosted_room_peer_controls WHERE {' AND '.join(clauses)}", + params, + ) + return cursor.rowcount + + +def update_peer_control_metadata( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + authority_gateway_id: Any, + authority_epoch: Any, + room_name: Any, + member_count: Any, + now: float | None = None, +) -> bool: + """Refresh presentation-only metadata after an authenticated home read.""" + + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + changed = conn.execute( + """UPDATE hosted_room_peer_controls + SET room_name=?, member_count=?, updated_at=? + WHERE room_id=? AND member_id=? AND authority_gateway_id=? + AND authority_epoch=? AND status='active'""", + ( + _room_name(room_name), + _member_count(member_count), + timestamp, + _identifier(room_id, label="room_id"), + _identifier(member_id, label="member_id"), + _identifier(authority_gateway_id, label="authority_gateway_id"), + _authority_epoch(authority_epoch), + ), + ) + if changed.rowcount not in {0, 1}: + raise HostedRoomControlError("control metadata changed more than once") + return changed.rowcount == 1 + + +def peer_reservation_matches( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + target_profile: Any, + authority_gateway_id: Any, + authority_epoch: Any, + now: float | None = None, +) -> bool: + """Bind a reciprocal link to the live target-side RoomLink reservation.""" + + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + target_profile = _identifier(target_profile, label="target_profile") + authority_gateway_id = _identifier( + authority_gateway_id, label="authority_gateway_id" + ) + authority_epoch = _authority_epoch(authority_epoch) + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path) as conn: + table = conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_room_peer_reservations'""" + ).fetchone() + if table is None: + return False + row = conn.execute( + """SELECT 1 FROM hosted_room_peer_reservations + WHERE room_id=? AND member_id=? AND target_profile=? + AND authority_gateway_id=? AND authority_epoch=? + AND expires_at>? AND revoked_at IS NULL""", + ( + room_id, + member_id, + target_profile, + authority_gateway_id, + authority_epoch, + timestamp, + ), + ).fetchone() + return row is not None + + +def begin_control_retry( + db_path: Path | str, + *, + command_id: Any, + room_id: Any, + member_id: Any, + task_ids: Any, + now: float | None = None, +) -> RoomControlCommandPlan: + """Freeze one remote retry delivery to one bounded task set.""" + + command_id = _identifier(command_id, label="command_id") + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + if not isinstance(task_ids, (list, tuple)) or len(task_ids) > 8: + raise HostedRoomControlError("retry task_ids must contain at most 8 tasks") + frozen = tuple(_identifier(value, label="task_id") for value in task_ids) + if len(set(frozen)) != len(frozen): + raise HostedRoomControlError("retry task_ids must be unique") + encoded = json.dumps(frozen, ensure_ascii=True, separators=(",", ":")) + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + if conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_rooms'""" + ).fetchone(): + conn.execute( + """DELETE FROM hosted_room_control_commands + WHERE room_id IN ( + SELECT room_id FROM hosted_rooms + WHERE disbanded_at IS NOT NULL + )""" + ) + existing = conn.execute( + "SELECT * FROM hosted_room_control_commands WHERE command_id=?", + (command_id,), + ).fetchone() + if existing is not None: + stored_tasks = tuple( + _identifier(value, label="task_id") + for value in json.loads(str(existing["task_ids_json"])) + ) + if ( + str(existing["room_id"]) != room_id + or str(existing["member_id"]) != member_id + or str(existing["action"]) != "retry" + or (frozen and stored_tasks != frozen) + ): + raise HostedRoomControlConflictError( + "control command conflicts with its durable retry plan" + ) + result = ( + json.loads(str(existing["result_json"])) + if existing["state"] == "completed" and existing["result_json"] + else None + ) + return RoomControlCommandPlan( + task_ids=stored_tasks, + result=result, + idempotent=True, + ) + count = conn.execute( + "SELECT COUNT(*) FROM hosted_room_control_commands" + ).fetchone()[0] + if int(count) >= MAX_CONTROL_COMMANDS: + raise HostedRoomControlError("stored control command limit reached") + if not frozen: + raise HostedRoomControlError("retry task_ids must contain 1-8 tasks") + conn.execute( + """INSERT INTO hosted_room_control_commands ( + command_id, room_id, member_id, action, task_ids_json, + state, result_json, created_at, updated_at + ) VALUES (?, ?, ?, 'retry', ?, 'pending', NULL, ?, ?)""", + (command_id, room_id, member_id, encoded, timestamp, timestamp), + ) + return RoomControlCommandPlan(task_ids=frozen, result=None, idempotent=False) + + +def load_pending_control_retries( + db_path: Path | str, + *, + room_id: Any, + limit: int = 8, +) -> tuple[PendingRoomControlRetry, ...]: + """Load a bounded retry queue for the process that owns the room lease.""" + + room_id = _identifier(room_id, label="room_id") + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64: + raise HostedRoomControlError("pending retry limit must be between 1 and 64") + pending: list[PendingRoomControlRetry] = [] + timestamp = time.time() + invalid_result = json.dumps( + {"action": "retry", "error": "invalid_stored_plan", "retried": 0}, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ) + with _transaction(db_path, immediate=True) as conn: + rows = conn.execute( + """SELECT command_id, room_id, member_id, task_ids_json + FROM hosted_room_control_commands + WHERE room_id=? AND action='retry' AND state='pending' + ORDER BY updated_at, created_at, command_id + LIMIT ?""", + (room_id, limit), + ).fetchall() + for row in rows: + try: + raw_task_ids = json.loads(str(row["task_ids_json"])) + if not isinstance(raw_task_ids, list): + raise HostedRoomControlError("stored retry task_ids are invalid") + task_ids = tuple( + _identifier(value, label="task_id") + for value in raw_task_ids + ) + if ( + not task_ids + or len(task_ids) > 8 + or len(set(task_ids)) != len(task_ids) + ): + raise HostedRoomControlError("stored retry task_ids are invalid") + pending.append( + PendingRoomControlRetry( + command_id=_identifier( + row["command_id"], label="command_id" + ), + room_id=_identifier(row["room_id"], label="room_id"), + member_id=_identifier( + row["member_id"], label="member_id" + ), + task_ids=task_ids, + ) + ) + except Exception: + conn.execute( + """UPDATE hosted_room_control_commands + SET state='completed', result_json=?, updated_at=? + WHERE command_id=? AND state='pending'""", + (invalid_result, timestamp, row["command_id"]), + ) + return tuple(pending) + + +def defer_control_retry( + db_path: Path | str, + *, + command_id: Any, + now: float | None = None, +) -> bool: + """Rotate a still-pending command behind newer work after a retry failure.""" + + command_id = _identifier(command_id, label="command_id") + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + changed = conn.execute( + """UPDATE hosted_room_control_commands + SET updated_at=? + WHERE command_id=? AND action='retry' AND state='pending'""", + (timestamp, command_id), + ) + return changed.rowcount == 1 + + +def complete_control_retry( + db_path: Path | str, + *, + command_id: Any, + result: Mapping[str, Any], + lease: Any | None = None, + now: float | None = None, +) -> dict[str, Any]: + """Commit one remote retry result idempotently.""" + + command_id = _identifier(command_id, label="command_id") + encoded = json.dumps( + dict(result), ensure_ascii=True, sort_keys=True, separators=(",", ":") + ) + if len(encoded.encode("utf-8")) > 16 * 1024: + raise HostedRoomControlError("control command result is too large") + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + if lease is not None: + from gateway import hosted_room_driver + + hosted_room_driver.require_active_lease_in_transaction( + conn, + lease, + now=timestamp, + ) + row = conn.execute( + "SELECT state, result_json FROM hosted_room_control_commands WHERE command_id=?", + (command_id,), + ).fetchone() + if row is None: + raise HostedRoomControlError("control retry plan is missing") + if row["state"] == "completed": + if str(row["result_json"] or "") != encoded: + raise HostedRoomControlConflictError( + "control retry result changed after completion" + ) + return json.loads(encoded) + changed = conn.execute( + """UPDATE hosted_room_control_commands + SET state='completed', result_json=?, updated_at=? + WHERE command_id=? AND state='pending'""", + (encoded, timestamp, command_id), + ) + if changed.rowcount != 1: + raise HostedRoomControlConflictError( + "control retry completion raced another result" + ) + return json.loads(encoded) diff --git a/gateway/hosted_room_driver.py b/gateway/hosted_room_driver.py index e2f836ca5185a..06681dc51f805 100644 --- a/gateway/hosted_room_driver.py +++ b/gateway/hosted_room_driver.py @@ -350,6 +350,22 @@ def _create_task_table( ) +def _initialize_retry_receipt_table(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_retry_receipts ( + retry_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + task_id TEXT NOT NULL, + source_execution_generation INTEGER NOT NULL + CHECK (source_execution_generation >= 0), + created_at REAL NOT NULL, + FOREIGN KEY (room_id, task_id) + REFERENCES hosted_room_driver_tasks(room_id, task_id) + ON DELETE CASCADE + )""" + ) + + def _initialize_schema(conn: sqlite3.Connection) -> None: conn.execute( """CREATE TABLE IF NOT EXISTS hosted_room_driver_leases ( @@ -366,6 +382,7 @@ def _initialize_schema(conn: sqlite3.Connection) -> None: )""" ) _create_task_table(conn) + _initialize_retry_receipt_table(conn) _validate_schema(conn) conn.execute( """CREATE INDEX IF NOT EXISTS idx_hosted_room_driver_tasks_status @@ -423,8 +440,37 @@ def _task_schema_supports_current_statuses(conn: sqlite3.Connection) -> bool: return "'stopping'" in sql and "'deferred'" in sql +def _task_schema_has_legacy_retry_id(conn: sqlite3.Connection) -> bool: + return any( + row[1] == "retry_id" + for row in conn.execute("PRAGMA table_info(hosted_room_driver_tasks)") + ) + + def _migrate_task_status_constraint(conn: sqlite3.Connection) -> None: """Expand the unpublished task-state CHECK without losing durable work.""" + preserved_receipts = [] + receipt_table = conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_room_retry_receipts'""" + ).fetchone() + if receipt_table is not None: + preserved_receipts.extend( + conn.execute( + """SELECT retry_id, room_id, task_id, + source_execution_generation, created_at + FROM hosted_room_retry_receipts""" + ).fetchall() + ) + if _task_schema_has_legacy_retry_id(conn): + preserved_receipts.extend( + conn.execute( + """SELECT retry_id, room_id, task_id, execution_generation, updated_at + FROM hosted_room_driver_tasks + WHERE retry_id IS NOT NULL AND retry_id != ''""" + ).fetchall() + ) + conn.execute("DROP TABLE IF EXISTS hosted_room_retry_receipts") conn.execute("DROP INDEX IF EXISTS idx_hosted_room_driver_tasks_status") _create_task_table(conn, "hosted_room_driver_tasks_next") columns = ", ".join(_TASK_COLUMN_ORDER) @@ -442,6 +488,36 @@ def _migrate_task_status_constraint(conn: sqlite3.Connection) -> None: room_id, status, source_event_seq, created_at, task_id )""" ) + _initialize_retry_receipt_table(conn) + for receipt in preserved_receipts: + existing = conn.execute( + "SELECT room_id, task_id FROM hosted_room_retry_receipts WHERE retry_id=?", + (str(receipt["retry_id"]),), + ).fetchone() + if existing is not None and ( + str(existing["room_id"]), str(existing["task_id"]) + ) != (str(receipt["room_id"]), str(receipt["task_id"])): + raise DriverStateError("draft retry_id is bound to multiple tasks") + conn.execute( + """INSERT OR IGNORE INTO hosted_room_retry_receipts( + retry_id, room_id, task_id, source_execution_generation, created_at + ) VALUES (?, ?, ?, ?, ?)""", + ( + str(receipt["retry_id"]), + str(receipt["room_id"]), + str(receipt["task_id"]), + int( + receipt["source_execution_generation"] + if "source_execution_generation" in receipt.keys() + else receipt["execution_generation"] + ), + float( + receipt["created_at"] + if "created_at" in receipt.keys() + else receipt["updated_at"] + ), + ), + ) def _connect(db_path: Path | str) -> sqlite3.Connection: @@ -455,10 +531,15 @@ def _connect(db_path: Path | str) -> sqlite3.Connection: apply_wal_with_fallback(conn, db_label="state.db (hosted_room_driver)") conn.execute("PRAGMA foreign_keys=ON") if _schema_objects_exist(conn): - if not _task_schema_supports_current_statuses(conn): + if ( + _task_schema_has_legacy_retry_id(conn) + or not _task_schema_supports_current_statuses(conn) + ): conn.execute("BEGIN IMMEDIATE") _migrate_task_status_constraint(conn) conn.commit() + _initialize_retry_receipt_table(conn) + conn.commit() _validate_schema(conn) return conn # Schema creation is one database-wide transaction. The driver schema @@ -488,6 +569,61 @@ def _transaction(db_path: Path | str) -> Iterator[sqlite3.Connection]: conn.close() +def _record_retry_receipt( + conn: sqlite3.Connection, + *, + retry_id: str | None, + row: sqlite3.Row, + now: float, +) -> None: + if retry_id is None: + return + retry_id = _identifier(retry_id, label="retry_id") + existing = conn.execute( + """SELECT room_id, task_id FROM hosted_room_retry_receipts + WHERE retry_id=?""", + (retry_id,), + ).fetchone() + if existing is not None: + if (str(existing["room_id"]), str(existing["task_id"])) != ( + str(row["room_id"]), + str(row["task_id"]), + ): + raise TaskConflictError("retry_id is already bound to another task") + return + conn.execute( + """INSERT INTO hosted_room_retry_receipts( + retry_id, room_id, task_id, source_execution_generation, created_at + ) VALUES (?, ?, ?, ?, ?)""", + ( + retry_id, + str(row["room_id"]), + str(row["task_id"]), + int(row["execution_generation"]), + now, + ), + ) + + +def retry_receipt_exists( + db_path: Path | str, + *, + room_id: Any, + task_id: Any, + retry_id: Any, +) -> bool: + room_id = _identifier(room_id, label="room_id") + task_id = _identifier(task_id, label="task_id") + retry_id = _identifier(retry_id, label="retry_id") + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT 1 FROM hosted_room_retry_receipts + WHERE retry_id=? AND room_id=? AND task_id=?""", + (retry_id, room_id, task_id), + ).fetchone() + return row is not None + + def _lease_from_row( row: sqlite3.Row | dict[str, Any], *, reclaimed: bool = False ) -> DriverLease: @@ -635,6 +771,63 @@ def _require_active_lease( return row +def require_active_lease_in_transaction( + conn: sqlite3.Connection, + lease: DriverLease, + *, + now: Any, +) -> DriverLease: + """Validate an exact lease inside a caller-owned SQLite transaction.""" + + timestamp = _timestamp(lambda: now) + return _lease_from_row(_require_active_lease(conn, lease, now=timestamp)) + + +def _cancel_task_behind_stop_fence( + conn: sqlite3.Connection, + row: sqlite3.Row, + *, + now: float, +) -> sqlite3.Row | None: + stop = conn.execute( + """SELECT seq FROM hosted_room_events + WHERE room_id=? AND kind='room.stop_requested' + ORDER BY seq DESC LIMIT 1""", + (row["room_id"],), + ).fetchone() + if stop is None or int(row["source_event_seq"]) >= int(stop["seq"]): + return None + cancel_id = f"stop-fence:{int(stop['seq'])}" + changed = conn.execute( + """UPDATE hosted_room_driver_tasks + SET status='cancelled', cancel_generation=cancel_generation + 1, + cancel_id=?, terminal_at=?, updated_at=? + WHERE room_id=? AND task_id=? AND status=? + AND execution_generation=? AND cancel_generation=?""", + ( + cancel_id, + now, + now, + row["room_id"], + row["task_id"], + row["status"], + int(row["execution_generation"]), + int(row["cancel_generation"]), + ), + ) + if changed.rowcount != 1: + raise StaleTaskError("task changed while applying the room stop fence") + return _load_task( + conn, + TaskIdentity( + room_id=str(row["room_id"]), + task_id=str(row["task_id"]), + thread_id=str(row["thread_id"]), + turn_id=str(row["turn_id"]), + ), + ) + + def acquire_lease( db_path: Path | str, *, @@ -754,6 +947,20 @@ def acquire_lease( return _lease_from_row(current, reclaimed=True) +def require_active_lease( + db_path: Path | str, + lease: DriverLease, + *, + clock: Clock, +) -> DriverLease: + """Revalidate one exact lease generation without extending its lifetime.""" + + now = _timestamp(clock) + with _transaction(db_path) as conn: + current = _require_active_lease(conn, lease, now=now) + return _lease_from_row(current) + + def renew_lease( db_path: Path | str, lease: DriverLease, @@ -915,7 +1122,7 @@ def start_task( *, expected_cancel_generation: int, clock: Clock, -) -> TaskAttempt: +) -> TaskAttempt | None: """Move one queued task to running under the current driver lease.""" if lease.room_id != identity.room_id: raise DriverValidationError("lease and task belong to different rooms") @@ -934,6 +1141,8 @@ def start_task( raise InvalidTaskTransitionError( f"cannot start task in state '{row['status']}'" ) + if _cancel_task_behind_stop_fence(conn, row, now=now) is not None: + return None unresolved = conn.execute( """SELECT task_id, status FROM hosted_room_driver_tasks WHERE room_id=? AND status IN ('running', 'indeterminate', 'stopping') @@ -1118,6 +1327,7 @@ def resolve_indeterminate_task( status: TerminalStatus, result: Any, clock: Clock, + retry_id: Any = None, ) -> dict[str, Any]: """Commit a verified historical receipt under the current room lease.""" if lease.room_id != identity.room_id: @@ -1135,6 +1345,7 @@ def resolve_indeterminate_task( ): raise DriverValidationError("expected_cancel_generation must be non-negative") settlement_id = _identifier(settlement_id, label="settlement_id") + retry_id = _identifier(retry_id, label="retry_id") if retry_id is not None else None if status not in {"settled", "failed"}: raise DriverValidationError("status must be 'settled' or 'failed'") result_json = _canonical_json(result) @@ -1149,6 +1360,7 @@ def resolve_indeterminate_task( and row["settlement_status"] == status and row["result_json"] == result_json ): + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(row, idempotent=True) raise TaskConflictError("task already has a different terminal settlement") if ( @@ -1178,6 +1390,7 @@ def resolve_indeterminate_task( ) if updated.rowcount != 1: raise StaleTaskError("indeterminate task changed during reconciliation") + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(_load_task(conn, identity)) @@ -1190,6 +1403,7 @@ def resolve_indeterminate_cancellation( expected_cancel_generation: int, cancel_id: Any, clock: Clock, + retry_id: Any = None, ) -> dict[str, Any]: """Commit a verified terminal cancellation for an uncertain attempt.""" if lease.room_id != identity.room_id: @@ -1207,11 +1421,13 @@ def resolve_indeterminate_cancellation( ): raise DriverValidationError("expected_cancel_generation must be non-negative") cancel_id = _identifier(cancel_id, label="cancel_id") + retry_id = _identifier(retry_id, label="retry_id") if retry_id is not None else None now = _timestamp(clock) with _transaction(db_path) as conn: _require_active_lease(conn, lease, now=now) row = _load_task(conn, identity) if row["status"] == "cancelled" and row["cancel_id"] == cancel_id: + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(row, idempotent=True) if ( row["status"] != "indeterminate" @@ -1238,6 +1454,7 @@ def resolve_indeterminate_cancellation( ) if updated.rowcount != 1: raise StaleTaskError("indeterminate cancellation proof lost its fence") + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(_load_task(conn, identity)) @@ -1249,6 +1466,7 @@ def requeue_indeterminate_task( expected_execution_generation: int, expected_cancel_generation: int, clock: Clock, + retry_id: Any = None, ) -> dict[str, Any]: """Explicitly retry uncertain work after an operator accepts at-least-once risk.""" if lease.room_id != identity.room_id: @@ -1265,6 +1483,7 @@ def requeue_indeterminate_task( or expected_cancel_generation < 0 ): raise DriverValidationError("expected_cancel_generation must be non-negative") + retry_id = _identifier(retry_id, label="retry_id") if retry_id is not None else None now = _timestamp(clock) with _transaction(db_path) as conn: _require_active_lease(conn, lease, now=now) @@ -1275,6 +1494,10 @@ def requeue_indeterminate_task( or int(row["cancel_generation"]) != expected_cancel_generation ): raise StaleTaskError("indeterminate task generation changed") + stopped = _cancel_task_behind_stop_fence(conn, row, now=now) + if stopped is not None: + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) + return _task_from_row(stopped) updated = conn.execute( """UPDATE hosted_room_driver_tasks SET status='queued', run_gateway_id=NULL, @@ -1292,6 +1515,7 @@ def requeue_indeterminate_task( ) if updated.rowcount != 1: raise StaleTaskError("indeterminate task changed during requeue") + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(_load_task(conn, identity)) @@ -1368,6 +1592,7 @@ def requeue_deferred_task( expected_execution_generation: int, expected_cancel_generation: int, clock: Clock, + retry_id: Any = None, ) -> dict[str, Any]: """Explicitly retry a fenced deferred turn under a new generation.""" @@ -1385,6 +1610,7 @@ def requeue_deferred_task( or expected_cancel_generation < 0 ): raise DriverValidationError("expected_cancel_generation must be non-negative") + retry_id = _identifier(retry_id, label="retry_id") if retry_id is not None else None now = _timestamp(clock) with _transaction(db_path) as conn: _require_active_lease(conn, lease, now=now) @@ -1395,6 +1621,10 @@ def requeue_deferred_task( or int(row["cancel_generation"]) != expected_cancel_generation ): raise StaleTaskError("deferred task generation changed") + stopped = _cancel_task_behind_stop_fence(conn, row, now=now) + if stopped is not None: + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) + return _task_from_row(stopped) updated = conn.execute( """UPDATE hosted_room_driver_tasks SET status='queued', run_gateway_id=NULL, @@ -1413,6 +1643,7 @@ def requeue_deferred_task( ) if updated.rowcount != 1: raise StaleTaskError("deferred task changed during requeue") + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(_load_task(conn, identity)) @@ -1559,7 +1790,7 @@ def begin_task_cancel( SET status='stopping', cancel_generation=?, cancel_id=?, updated_at=? WHERE room_id=? AND task_id=? - AND status IN ('running', 'indeterminate') + AND status IN ('running', 'indeterminate', 'deferred') AND cancel_generation=?""", ( expected_cancel_generation + 1, diff --git a/gateway/hosted_room_messaging.py b/gateway/hosted_room_messaging.py new file mode 100644 index 0000000000000..8bbd4ae7c7d52 --- /dev/null +++ b/gateway/hosted_room_messaging.py @@ -0,0 +1,1957 @@ +"""Messaging-facing controls for gateway-hosted Bot rooms. + +The handlers in :mod:`gateway.slash_commands` deliberately stay thin. This +module owns parsing, room lookup, bounded presentation, and server-owned actor +identity so Telegram, Signal, WhatsApp, and the other gateway transports share +one behavior contract. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import sqlite3 +import time +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from gateway import hosted_room_discussion as discussion +from gateway import hosted_room_driver as driver +from gateway import hosted_room_controls, hosted_room_links +from gateway import hosted_rooms +from gateway.hosted_room_control_client import ( + RoomControlClientError, + RoomControlHTTPClient, +) + + +MAX_ROOM_CHOICES = 8 +MAX_RECENT_MESSAGES = 5 +MAX_PREVIEW_CHARS = 180 +MAX_GROUP_MEMBERS = 6 +MAX_MESSAGING_ROOMS = 4096 +GROUP_CHAT_SYNC_META_KEY = "hermes-bots-groups" + + +def _projected_desktop_rooms(*, profile: str = "default") -> list[dict[str, Any]]: + """Read the bounded classic-room projection shared by Desktop clients.""" + + try: + import yaml + from hermes_cli.profiles import get_profile_dir + + profile_meta = Path(get_profile_dir(profile)) / "profile.yaml" + if not profile_meta.is_file(): + return [] + raw = yaml.safe_load(profile_meta.read_text(encoding="utf-8")) or {} + ui_meta = raw.get("ui_meta") if isinstance(raw, Mapping) else None + snapshot = ( + ui_meta.get(GROUP_CHAT_SYNC_META_KEY) + if isinstance(ui_meta, Mapping) + else None + ) + raw_rooms = snapshot.get("rooms") if isinstance(snapshot, Mapping) else None + except Exception: + return [] + if not isinstance(raw_rooms, Mapping): + return [] + try: + snapshot_version = int(snapshot.get("version") or 0) + except (TypeError, ValueError): + snapshot_version = 0 + + rooms: list[dict[str, Any]] = [] + for key, raw_room in raw_rooms.items(): + if not isinstance(raw_room, Mapping): + continue + hosted = raw_room.get("hosted") + if isinstance(hosted, str) and hosted.strip(): + continue + name = _clean_line(raw_room.get("name") or key, limit=200) + explicit_room_id = str(raw_room.get("roomId") or "").strip() + room_id = ( + explicit_room_id + or (str(key).strip() if snapshot_version >= 3 else f"name:{name}") + ) + if ( + not name + or not room_id + or len(room_id) > 200 + or any(char in room_id for char in ("\x00", "\r", "\n")) + ): + continue + authority_hash = str(raw_room.get("desktopAuthorityHash") or "").strip().lower() + if not re.fullmatch(r"[a-f0-9]{64}", authority_hash): + authority_hash = "" + raw_log = raw_room.get("log") + log = [dict(item) for item in raw_log if isinstance(item, Mapping)] if isinstance(raw_log, list) else [] + raw_members = raw_room.get("members") + members = ( + [dict(item) for item in raw_members if isinstance(item, Mapping)] + if isinstance(raw_members, list) + else [] + ) + updated_at = max( + (float(item.get("at") or 0) for item in log), + default=float(snapshot.get("updatedAt") or 0) / 1000, + ) + rooms.append( + { + "room_id": room_id, + "name": name, + "members": members, + "log": log, + "created_at": min( + (float(item.get("at") or 0) for item in log), + default=updated_at, + ), + "updated_at": updated_at, + "desktop_authority_hash": authority_hash, + "_room_mode": "desktop", + } + ) + return rooms + + +def _room_profiles(room: Mapping[str, Any]) -> set[str]: + profiles: set[str] = set() + members = room.get("members") + if not isinstance(members, list): + return profiles + for member in members: + if not isinstance(member, Mapping): + continue + profile = str( + member.get("target_profile") + or member.get("profile") + or member.get("member_id") + or "" + ).strip() + if profile: + profiles.add(profile) + target = member.get("target") + if isinstance(target, Mapping): + target_profile = str( + target.get("target_profile") or target.get("profile") or "" + ).strip() + if target_profile: + profiles.add(target_profile) + return profiles + + +def list_messaging_rooms( + service: Any, + *, + profile: str = "default", +) -> list[dict[str, Any]]: + """Return hosted and reachable classic rooms with stable short numbers.""" + + hosted: list[dict[str, Any]] = [] + offset = 0 + while offset < MAX_MESSAGING_ROOMS: + page = hosted_rooms.list_rooms( + service.db_path, + limit=hosted_rooms.MAX_ROOM_LIST_LIMIT, + offset=offset, + ) + hosted.extend({**room, "_room_mode": "hosted"} for room in page) + if len(page) < hosted_rooms.MAX_ROOM_LIST_LIMIT: + break + offset += len(page) + if len(hosted) >= MAX_MESSAGING_ROOMS: + raise RoomControlError( + "There are too many Group Chats to list safely. Disband inactive chats and try again." + ) + # The default profile is the installation owner's master chat. Secondary + # profiles see only rooms whose frozen roster includes that profile. + if profile != "default": + hosted = [room for room in hosted if profile in _room_profiles(room)] + hosted_ids = {str(room["room_id"]) for room in hosted} + remote: list[dict[str, Any]] = [] + remote_ids: set[str] = set() + try: + peer_links = hosted_room_controls.load_peer_control_links( + service.db_path, + limit=hosted_room_controls.MAX_LOAD_LINKS, + ).links + except hosted_room_controls.HostedRoomControlError: + peer_links = () + for link in peer_links: + if link.room_id in hosted_ids or link.room_id in remote_ids: + continue + if profile != "default" and not hosted_room_controls.peer_reservation_matches( + service.db_path, + room_id=link.room_id, + member_id=link.member_id, + target_profile=profile, + authority_gateway_id=link.authority_gateway_id, + authority_epoch=link.authority_epoch, + ): + continue + remote_ids.add(link.room_id) + remote.append( + { + "room_id": link.room_id, + "name": link.room_name, + "members": [], + "member_count": link.member_count, + "authority_gateway_id": link.authority_gateway_id, + "authority_epoch": link.authority_epoch, + "created_at": link.created_at, + "updated_at": link.updated_at, + "_remote_member_id": link.member_id, + "_room_mode": "remote", + } + ) + desktop = [ + room + for room in _projected_desktop_rooms(profile=profile) + if str(room["room_id"]) not in hosted_ids | remote_ids + ] + rooms = hosted + remote + desktop + if not rooms: + return [] + if len(rooms) > MAX_MESSAGING_ROOMS: + raise RoomControlError( + "There are too many Group Chats to list safely. Disband " + "inactive chats and try again." + ) + + from gateway.desktop_room_mailbox import ( + MAX_ROOM_IDS, + available_room_ids, + default_db_path, + failed_command_counts, + latest_command_states, + register_projected_authorities, + ) + + mailbox_db = default_db_path() + desktop_ids = [room["room_id"] for room in desktop] + commitments = [ + { + "room_id": room["room_id"], + "authority_hash": room["desktop_authority_hash"], + } + for room in desktop + if room.get("desktop_authority_hash") + ] + for index in range(0, len(commitments), MAX_ROOM_IDS): + register_projected_authorities( + mailbox_db, + commitments[index : index + MAX_ROOM_IDS], + ) + available = available_room_ids(mailbox_db, desktop_ids) + command_states = latest_command_states(mailbox_db, desktop_ids) + failed_counts = failed_command_counts(mailbox_db, desktop_ids) + rooms = [ + { + **room, + **( + { + "desktop_available": str(room["room_id"]) in available, + "desktop_command": command_states.get(str(room["room_id"])), + "desktop_failed_commands": failed_counts.get( + str(room["room_id"]), 0 + ), + } + if room.get("_room_mode") == "desktop" + else {} + ), + } + for room in rooms + ] + + # Keep the numeric reference ledger where #96274 first created it. Moving + # it to the compatibility mailbox would silently renumber existing group + # chats after upgrade, making `/group 2 send ...` target the wrong chat. + db_path = Path(service.db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path, timeout=10) + conn.row_factory = sqlite3.Row + try: + from hermes_state import apply_wal_with_fallback + + apply_wal_with_fallback(conn, db_label="state.db (room messaging refs)") + conn.execute("BEGIN IMMEDIATE") + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_messaging_refs ( + room_ref INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL UNIQUE + )""" + ) + # Existing rooms receive deterministic first-use numbers. AUTOINCREMENT + # keeps those numbers stable and prevents a disbanded room's reference + # from being reassigned to unrelated work later. + for room in sorted( + rooms, + key=lambda item: ( + float(item.get("created_at") or 0), + str(item.get("room_id") or ""), + ), + ): + conn.execute( + "INSERT OR IGNORE INTO hosted_room_messaging_refs (room_id) VALUES (?)", + (str(room["room_id"]),), + ) + refs = { + str(row["room_id"]): int(row["room_ref"]) + for row in conn.execute( + "SELECT room_id, room_ref FROM hosted_room_messaging_refs" + ) + } + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + return [ + {**room, "messaging_ref": refs[str(room["room_id"])]} + for room in rooms + ] + + +def room_reference(room: Mapping[str, Any]) -> str: + """Return the short messaging reference, with an internal-id fallback.""" + + reference = room.get("messaging_ref") + if isinstance(reference, int) and reference > 0: + return str(reference) + return str(room.get("room_id") or "") + + +def _frozen_desktop_recipients(room: Mapping[str, Any]) -> list[dict[str, Any]]: + """Return the bounded routing identity visible in the trusted projection.""" + + raw_members = room.get("members") + if not isinstance(raw_members, list): + return [] + recipients: list[dict[str, Any]] = [] + for raw in raw_members[:MAX_GROUP_MEMBERS]: + if not isinstance(raw, Mapping): + continue + name = str(raw.get("name") or "").strip()[:128] + if not name: + continue + recipient: dict[str, Any] = {"name": name} + for key, limit in ( + ("handle", 128), + ("connectionId", 128), + ("connectionKind", 64), + ("connectionLabel", 128), + ): + value = str(raw.get(key) or "").strip() + if value: + recipient[key] = value[:limit] + if raw.get("sourceScoped") is True: + recipient["sourceScoped"] = True + recipients.append(recipient) + if len(recipients) < 2: + raise RoomControlError( + "This Group Chat’s Bot list is incomplete. Open it in Hermes Desktop and try again." + ) + return recipients + + +def _latest_projected_thread(room: Mapping[str, Any]) -> str: + raw_log = room.get("log") + if not isinstance(raw_log, list): + return "" + for entry in reversed(raw_log): + if not isinstance(entry, Mapping): + continue + thread = str(entry.get("thread") or "").strip() + if thread: + return thread[:128] + return "" + + +class RoomControlError(ValueError): + """A user-actionable hosted-room command error.""" + + +def _room_member_count(room: Mapping[str, Any]) -> int: + value = room.get("member_count") + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return value + members = room.get("members") + return len(members) if isinstance(members, list) else 0 + + +def _room_status_icon(status: str) -> str: + lowered = str(status or "").casefold() + if any( + word in lowered + for word in ("attention", "blocked", "error", "offline", "unavailable") + ): + return "⚠️" + if "connected" in lowered: + return "⚪" + if any( + word in lowered + for word in ("queued", "running", "stopping", "waiting", "working") + ): + return "🟡" + return "🟢" + + +def _picker_display_label(value: Any, *, limit: int) -> str: + """Neutralize rich markup and notification-shaped @mentions in pickers.""" + + return _plain_display_label(value, limit=limit) + + +def room_picker_choices( + service: Any, + rooms: list[Mapping[str, Any]], +) -> list[dict[str, Any]]: + """Build one bounded native-picker page without exposing room internals.""" + + choices: list[dict[str, Any]] = [] + for room in sorted( + rooms, + key=lambda item: ( + -float(item.get("updated_at") or item.get("created_at") or 0), + int(item.get("messaging_ref") or 0), + ), + )[:MAX_ROOM_CHOICES]: + reference = room_reference(room) + name = _clean_line(room.get("name") or room.get("room_id"), limit=42) + status = _room_status(service, room) + count = _room_member_count(room) + choices.append( + { + "value": _room_picker_value(room), + "label": ( + f"{_room_status_icon(status)} {reference}. " + f"{_picker_display_label(name, limit=42)} ({count})" + ), + "full_width": True, + "is_current": False, + } + ) + return choices + + +def _room_picker_value(room: Mapping[str, Any]) -> str: + seed = ":".join( + ( + str(room.get("_room_mode") or "hosted"), + str(room.get("connection_id") or room.get("_connection_id") or ""), + str(room.get("room_id") or ""), + ) + ) + return f"room-{hashlib.sha256(seed.encode()).hexdigest()[:16]}" + + +def resolve_room_picker_choice( + rooms: list[Mapping[str, Any]], + value: str, +) -> Mapping[str, Any]: + """Resolve an exact native-picker room token without number reuse.""" + + selected = [room for room in rooms if _room_picker_value(room) == str(value)] + if len(selected) != 1: + raise RoomControlError("This Group Chat is no longer available. Run the command again.") + return selected[0] + + +def _room_participant_lines(room: Mapping[str, Any]) -> list[str]: + raw_members = room.get("members") + if not isinstance(raw_members, list): + return [] + lines: list[str] = [] + for raw in raw_members[:MAX_GROUP_MEMBERS]: + if not isinstance(raw, Mapping): + continue + name = _room_member_name(raw) + handle = _room_member_handle(raw) + suffix = f" (`@{handle}`)" if handle else "" + lines.append(f"• {_plain_display_label(name)}{suffix}") + return lines + + +def _room_member_name(member: Mapping[str, Any]) -> str: + return _clean_line( + member.get("display_name") + or member.get("displayName") + or member.get("name") + or member.get("handle") + or "Bot", + limit=48, + ) + + +def _room_member_handle(member: Mapping[str, Any]) -> str: + handle = _clean_line( + member.get("handle"), + limit=driver.MAX_IDENTIFIER_CHARS, + ).lstrip("@") + return handle if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]*", handle) else "" + + +def _room_member_picker_value( + room: Mapping[str, Any], + member: Mapping[str, Any], +) -> str: + seed = json.dumps( + [ + str(room.get("room_id") or ""), + str(member.get("member_id") or ""), + _room_member_handle(member), + _room_member_name(member), + ], + ensure_ascii=False, + separators=(",", ":"), + ) + return f"p={hashlib.sha256(seed.encode()).hexdigest()[:16]}" + + +def _room_display_members(service: Any, room: Mapping[str, Any]) -> list[Mapping[str, Any]]: + if room.get("_room_mode") == "remote": + summary = _remote_summary(service, room) + remote_room = summary.get("room") + raw_members = ( + remote_room.get("members") + if isinstance(remote_room, Mapping) + else None + ) + else: + raw_members = room.get("members") + return [ + member + for member in (raw_members if isinstance(raw_members, list) else []) + if isinstance(member, Mapping) + ][:MAX_GROUP_MEMBERS] + + +def _room_with_messaging_reference( + service: Any, + room: Mapping[str, Any], +) -> Mapping[str, Any]: + if isinstance(room.get("messaging_ref"), int): + return room + room_id = str(room.get("room_id") or "") + return next( + ( + candidate + for candidate in list_messaging_rooms(service) + if str(candidate.get("room_id") or "") == room_id + ), + room, + ) + + +def room_bot_picker_choices( + service: Any, + room: Mapping[str, Any], +) -> list[dict[str, Any]]: + """Build a native participant picker without exposing profile internals.""" + + room = _room_with_messaging_reference(service, room) + choices: list[dict[str, Any]] = [] + for member in _room_display_members(service, room): + name = _room_member_name(member) + handle = _room_member_handle(member) + choices.append( + { + "value": _room_member_picker_value(room, member), + "label": f"🤖 {_picker_display_label(name, limit=48)}" + + (f" · {handle}" if handle else ""), + "full_width": True, + "is_current": False, + } + ) + return choices + + +def format_room_bot_list( + service: Any, + room: Mapping[str, Any], + *, + room_command: str = "/group", +) -> str: + """Render a bounded participant list with one stable in-roster number.""" + + room = _room_with_messaging_reference(service, room) + members = _room_display_members(service, room) + name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + reference = room_reference(room) + lines = [f"🤖 **Bots in {_plain_display_label(name, limit=72)}**"] + for index, member in enumerate(members, start=1): + member_name = _room_member_name(member) + handle = _room_member_handle(member) + lines.append( + f"{index}. **{_plain_display_label(member_name)}**" + + (f" · `@{handle}`" if handle else "") + ) + if not members: + lines.append("No Bots are available in this Group Chat.") + lines.extend( + [ + "", + "────────", + "🧭 **Controls**", + f"Bot details: `{room_command} {reference} bot `", + f"Back to Group Chat: `{room_command} {reference}`", + ] + ) + return "\n".join(lines) + + +def format_room_bot_detail( + service: Any, + room: Mapping[str, Any], + bot_query: str, + *, + room_command: str = "/group", +) -> str: + """Show one participant and only the controls the room contract supports.""" + + room = _room_with_messaging_reference(service, room) + members = _room_display_members(service, room) + raw_query = str(bot_query or "").strip() + normalized = raw_query.lstrip("@").casefold() + selected: Mapping[str, Any] | None = None + if raw_query.startswith("p="): + token_matches = [ + member + for member in members + if _room_member_picker_value(room, member) == raw_query + ] + if len(token_matches) == 1: + selected = token_matches[0] + elif not raw_query.startswith("@") and normalized.isdecimal(): + index = int(normalized) + if 1 <= index <= len(members): + selected = members[index - 1] + else: + matches = [ + member + for member in members + if normalized + in { + _room_member_handle(member).casefold(), + _room_member_name(member).casefold(), + _room_member_picker_value(room, member).casefold(), + } + ] + if len(matches) == 1: + selected = matches[0] + if selected is None: + raise RoomControlError("No Bot in this Group Chat matches that number or handle.") + + room_name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + reference = room_reference(room) + member_name = _room_member_name(selected) + handle = _room_member_handle(selected) + lines = [ + f"🤖 **{_plain_display_label(member_name)}**", + f"Group Chat: {_plain_display_label(room_name, limit=72)}", + ] + if handle: + lines.append(f"Handle: `@{handle}`") + lines.extend(["", "────────", "🧭 **Controls**"]) + if handle: + lines.append( + f"Message this Bot: `{room_command} {reference} send @{handle} `" + ) + lines.extend( + [ + f"All Bots: `{room_command} {reference} bots`", + f"Back to Group Chat: `{room_command} {reference}`", + ] + ) + return "\n".join(lines) + + +def _room_has_targetable_handle(room: Mapping[str, Any]) -> bool: + raw_members = room.get("members") + if not isinstance(raw_members, list): + return False + return any( + isinstance(raw, Mapping) + and bool( + re.sub( + r"[^A-Za-z0-9_.-]", + "", + _clean_line(raw.get("handle"), limit=48).lstrip("@"), + ) + ) + for raw in raw_members[:MAX_GROUP_MEMBERS] + ) + + +def _remote_control_link(service: Any, room: Mapping[str, Any]): + room_id = str(room.get("room_id") or "") + member_id = str(room.get("_remote_member_id") or "") + try: + links = hosted_room_controls.load_peer_control_links( + service.db_path, + limit=hosted_room_controls.MAX_LOAD_LINKS, + ).links + except hosted_room_controls.HostedRoomControlError as exc: + raise RoomControlError( + "This Group Chat connection needs repair. Open it in Hermes Desktop " + "on a connected device." + ) from exc + link = next( + ( + candidate + for candidate in links + if candidate.room_id == room_id and candidate.member_id == member_id + ), + None, + ) + if link is None: + raise RoomControlError( + "This Group Chat isn’t available here. Open it in Hermes Desktop or " + "another connected Hermes chat." + ) + return link + + +def _remote_error(exc: RoomControlClientError) -> RoomControlError: + if exc.status_code == 400 and exc.user_message: + return RoomControlError(exc.user_message) + if exc.status_code in {401, 403, 404}: + return RoomControlError( + "This Group Chat isn’t available here anymore. Open it in Hermes " + "Desktop or another connected Hermes chat." + ) + return RoomControlError( + "This Group Chat can’t be reached right now. Make sure the devices running " + "its Bots are online, then try again." + ) + + +def _remote_summary(service: Any, room: Mapping[str, Any]) -> dict[str, Any]: + link = _remote_control_link(service, room) + try: + summary = RoomControlHTTPClient(link).summary() + except RoomControlClientError as exc: + raise _remote_error(exc) from exc + raw_room = summary.get("room") + if not isinstance(raw_room, Mapping): + raise RoomControlError("This Group Chat returned invalid status data.") + try: + authority_epoch = int(raw_room.get("authority_epoch") or 0) + except (TypeError, ValueError) as exc: + raise RoomControlError("This Group Chat returned invalid status data.") from exc + if ( + str(raw_room.get("room_id") or "") != link.room_id + or str(raw_room.get("authority_gateway_id") or "") + != link.authority_gateway_id + or authority_epoch != link.authority_epoch + ): + raise RoomControlError("This Group Chat returned mismatched status data.") + raw_members = raw_room.get("members") + if not isinstance(raw_members, list) or not 1 <= len(raw_members) <= 64: + raise RoomControlError("This Group Chat returned invalid member data.") + hosted_room_controls.update_peer_control_metadata( + service.db_path, + room_id=link.room_id, + member_id=link.member_id, + authority_gateway_id=link.authority_gateway_id, + authority_epoch=link.authority_epoch, + room_name=raw_room.get("name") or link.room_name, + member_count=len(raw_members), + ) + return summary + + +def _remote_mutate( + service: Any, + room: Mapping[str, Any], + *, + action: str, + command_id: str, + text: str = "", + actor_display_name: str = "Messaging", +) -> dict[str, Any]: + link = _remote_control_link(service, room) + try: + return RoomControlHTTPClient(link).mutate( + action=action, + command_id=command_id, + text=text, + actor_display_name=actor_display_name, + ) + except RoomControlClientError as exc: + raise _remote_error(exc) from exc + + +def _retry_receipt_plan( + db_path: Path, + *, + command_id: str, + room_id: str, + actor: Mapping[str, Any], + task_ids: list[str], +) -> tuple[list[str], str | None]: + """Freeze one transport delivery to one bounded retry decision.""" + + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path, timeout=10) + conn.row_factory = sqlite3.Row + try: + from hermes_state import apply_wal_with_fallback + + apply_wal_with_fallback(conn, db_label="state.db (Group Chat retry receipts)") + conn.execute("BEGIN IMMEDIATE") + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_messaging_retries ( + command_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + actor_json TEXT NOT NULL, + task_ids_json TEXT NOT NULL, + state TEXT NOT NULL, + result_text TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + encoded_actor = json.dumps( + dict(actor), ensure_ascii=True, sort_keys=True, separators=(",", ":") + ) + existing = conn.execute( + "SELECT * FROM hosted_room_messaging_retries WHERE command_id = ?", + (command_id,), + ).fetchone() + if existing is not None: + if ( + str(existing["room_id"]) != room_id + or str(existing["actor_json"]) != encoded_actor + ): + raise RoomControlError( + "This retry delivery was already used for different Group Chat work." + ) + frozen = [ + str(item) + for item in json.loads(str(existing["task_ids_json"])) + if str(item) + ] + result = ( + str(existing["result_text"]) + if existing["state"] == "completed" and existing["result_text"] + else None + ) + conn.commit() + return frozen, result + if not task_ids: + raise RoomControlError("This Group Chat has no failed work to retry.") + now = time.time() + conn.execute( + """INSERT INTO hosted_room_messaging_retries ( + command_id, room_id, actor_json, task_ids_json, state, + result_text, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', NULL, ?, ?)""", + ( + command_id, + room_id, + encoded_actor, + json.dumps(task_ids, ensure_ascii=True, separators=(",", ":")), + now, + now, + ), + ) + conn.commit() + return task_ids, None + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _complete_retry_receipt(db_path: Path, *, command_id: str, result: str) -> None: + conn = sqlite3.connect(db_path, timeout=10) + try: + conn.execute("BEGIN IMMEDIATE") + changed = conn.execute( + """UPDATE hosted_room_messaging_retries + SET state='completed', result_text=?, updated_at=? + WHERE command_id=? AND state='pending'""", + (result, time.time(), command_id), + ) + if changed.rowcount not in {0, 1}: + raise RuntimeError("Group Chat retry receipt changed more than once") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +@dataclass(frozen=True) +class RoomCommand: + """Parsed mutating ``/group`` subcommand.""" + + action: str + room_query: str + message: str = "" + + +class MessagingRoomBackend: + """Cross-process hosted-room access through the shared durable stores.""" + + def __init__(self, *, db_path: Any, service: Any = None) -> None: + self.db_path = db_path + self.service = service + + def status(self, room_id: str) -> dict[str, Any]: + if self.service is not None: + status = self.service.status(room_id) + peer_needs_attention = any( + str(route.get("status") or "") == "needs_reauthorization" + for route in status.get("peer_routes", []) + if isinstance(route, Mapping) + ) + return {**status, "blocked": bool(status.get("blocked") or peer_needs_attention)} + tasks = driver.list_tasks(self.db_path, room_id=room_id) + counts: dict[str, int] = {} + for task in tasks: + status = str(task.get("status") or "") + counts[status] = counts.get(status, 0) + 1 + try: + peer_needs_attention = any( + link.room_id == room_id and link.status == "needs_reauthorization" + for link in hosted_room_links.load_room_links(self.db_path) + ) + except Exception: + peer_needs_attention = True + return { + "working": any(counts.get(status) for status in ("queued", "running", "stopping")), + "blocked": bool(counts.get("indeterminate") or peer_needs_attention), + "counts": counts, + "pending_actions": [ + {"kind": "retry", "task_id": task["identity"].task_id} + for task in tasks + if task.get("status") in {"deferred", "indeterminate"} + ], + } + + def send( + self, + *, + room_id: str, + event_id: str, + payload: Any, + actor: Mapping[str, Any], + ) -> dict[str, Any]: + if self.service is not None: + send_server_owned = getattr(self.service, "send_server_owned", None) + if callable(send_server_owned): + return send_server_owned( + room_id=room_id, + event_id=event_id, + payload=payload, + actor=actor, + ) + return self.service.send( + room_id=room_id, + event_id=event_id, + payload=payload, + actor=actor, + ) + room = hosted_rooms.room_state(self.db_path, room_id=room_id) + local_gateway_id = hosted_rooms.local_authority_gateway_id() + if str(room["authority_gateway_id"]) != local_gateway_id: + raise hosted_rooms.AuthorityConflictError( + "This Group Chat moved to another connected device. Open it there and try again." + ) + normalized = discussion.validate_user_payload(payload) + return hosted_rooms.append_event( + self.db_path, + room_id=room_id, + event_id=event_id, + kind="message.user", + actor=dict(actor), + payload=normalized, + authority_gateway_id=str(room["authority_gateway_id"]), + authority_epoch=int(room["authority_epoch"]), + ) + + def stop_room(self, room_id: str, *, cancel_id: str) -> int: + if self.service is not None: + return self.service.stop_room(room_id, cancel_id=cancel_id) + room = hosted_rooms.room_state(self.db_path, room_id=room_id) + local_gateway_id = hosted_rooms.local_authority_gateway_id() + if str(room["authority_gateway_id"]) != local_gateway_id: + raise hosted_rooms.AuthorityConflictError( + "This Group Chat moved to another connected device. Open it there and try again." + ) + hosted_rooms.request_room_stop( + self.db_path, + room_id=room_id, + cancel_id=cancel_id, + expected_gateway_id=local_gateway_id, + expected_epoch=int(room["authority_epoch"]), + ) + requested = 0 + for task in driver.list_tasks(self.db_path, room_id=room_id): + for _attempt in range(3): + current = driver.get_task(self.db_path, task["identity"]) + status = str(current.get("status") or "") + try: + if status == "queued": + driver.cancel_task( + self.db_path, + current["identity"], + cancel_id=cancel_id, + expected_cancel_generation=int( + current["cancel_generation"] + ), + clock=time.time, + ) + requested += 1 + elif status in {"running", "indeterminate", "deferred"}: + driver.begin_task_cancel( + self.db_path, + current["identity"], + cancel_id=cancel_id, + expected_cancel_generation=int( + current["cancel_generation"] + ), + clock=time.time, + ) + requested += 1 + elif status == "stopping": + requested += 1 + elif ( + status == "cancelled" + and current.get("cancel_id") == cancel_id + ): + requested += 1 + break + except (driver.InvalidTaskTransitionError, driver.StaleTaskError): + # Queued work can become running between the list and the + # write. Reload and retry under the new generation. + continue + return requested + + def retry_room_task( + self, + room_id: str, + *, + task_id: str, + retry_id: str | None = None, + ) -> dict[str, Any]: + if self.service is None: + raise RoomControlError( + "Retry is available when the device running this Group Chat is online." + ) + return self.service.retry_room_task( + room_id, + task_id=task_id, + retry_id=retry_id, + ) + + +def current_room_backend() -> MessagingRoomBackend: + """Resolve in-process service access or the shared cross-process store.""" + + from tui_gateway.methods_groups import get_hosted_room_service + + service = get_hosted_room_service() + return MessagingRoomBackend( + db_path=service.db_path if service is not None else hosted_rooms.default_db_path(), + service=service, + ) + + +def _clean_line(value: Any, *, limit: int = MAX_PREVIEW_CHARS) -> str: + """Collapse untrusted text to one bounded display line.""" + + text = re.sub(r"\s+", " ", str(value or "")).strip() + if len(text) <= limit: + return text + return text[: max(1, limit - 1)].rstrip() + "…" + + +def _plain_display_label(value: Any, *, limit: int = MAX_PREVIEW_CHARS) -> str: + """Neutralize markup-shaped labels before placing them in rich layouts.""" + + text = _clean_line(value, limit=limit) + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) + text = re.sub(r"[\\`*_{}\[\]#|>~]", "", text).strip() + return text.replace("@", "@") or "Unnamed" + + +def _plain_preview_text(value: Any, *, limit: int = MAX_PREVIEW_CHARS) -> str: + """Neutralize preview markup without deleting message content.""" + + text = _clean_line(value, limit=limit) + return text.translate( + str.maketrans( + { + "@": "@", + "\\": "\", + "`": "`", + "*": "*", + "_": "_", + "{": "{", + "}": "}", + "[": "[", + "]": "]", + "#": "#", + "|": "|", + ">": ">", + "~": "~", + } + ) + ) + + +def parse_room_command(args: str, *, command_root: str = "/group") -> RoomCommand: + """Parse the number-first send/retry/stop grammar used by messaging clients.""" + + raw = str(args or "").strip() + entity_first = raw.split(maxsplit=2) + if len(entity_first) < 2 or not entity_first[0].isdecimal(): + raise RoomControlError( + f"Use `{command_root} send `, " + f"`{command_root} retry`, or `{command_root} stop`." + ) + room_query = entity_first[0] + action = entity_first[1].casefold() + remainder = entity_first[2].strip() if len(entity_first) == 3 else "" + if action == "send": + message = remainder.removeprefix("--").strip() + if len(message) >= 2 and message[0] == message[-1] and message[0] in {'"', "'"}: + message = message[1:-1].strip() + if not message: + raise RoomControlError( + f"Use `{command_root} send `." + ) + return RoomCommand("send", room_query, message) + if action == "stop": + if remainder: + raise RoomControlError(f"Use `{command_root} stop`.") + return RoomCommand("stop", room_query) + if action == "retry": + if remainder: + raise RoomControlError(f"Use `{command_root} retry`.") + return RoomCommand("retry", room_query) + raise RoomControlError( + f"Use `{command_root} send `, " + f"`{command_root} retry`, or `{command_root} stop`." + ) + + +def resolve_room(rooms: list[dict[str, Any]], query: str) -> dict[str, Any]: + """Resolve by stable messaging number, then id/name convenience matches.""" + + needle = _clean_line(query, limit=hosted_rooms.MAX_ROOM_NAME_CHARS).casefold() + if not needle: + raise RoomControlError("Enter a room number or name.") + + if needle.isdecimal(): + numeric_ref = int(needle) + matches = [ + room for room in rooms if room.get("messaging_ref") == numeric_ref + ] + if len(matches) == 1: + return matches[0] + raise RoomControlError(f"No Group Chat is numbered {numeric_ref}.") + + if needle.startswith("id:"): + internal_id = needle.removeprefix("id:") + matches = [ + room + for room in rooms + if str(room.get("room_id") or "").casefold() == internal_id + ] + if len(matches) == 1: + return matches[0] + raise RoomControlError("No Group Chat matches that internal ID.") + + def _keys(room: Mapping[str, Any]) -> tuple[str, str]: + return ( + str(room.get("room_id") or "").casefold(), + str(room.get("name") or "").casefold(), + ) + + for match_kind in ("exact", "prefix", "contains"): + matches: list[dict[str, Any]] = [] + for room in rooms: + room_id, name = _keys(room) + if match_kind == "exact" and needle in {room_id, name}: + matches.append(room) + elif match_kind == "prefix" and ( + room_id.startswith(needle) or name.startswith(needle) + ): + matches.append(room) + elif match_kind == "contains" and (needle in room_id or needle in name): + matches.append(room) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + names = ", ".join( + ( + f"{_clean_line(room.get('name') or room.get('room_id'), limit=48)} " + f"[{room_reference(room)}]" + ) + for room in matches[:MAX_ROOM_CHOICES] + ) + suffix = "…" if len(matches) > MAX_ROOM_CHOICES else "" + raise RoomControlError( + f"That matches several group chats: {names}{suffix}. Enter more of the name." + ) + raise RoomControlError(f"No group chat matches “{_clean_line(query)}”.") + + +def _room_status(service: Any, room: Mapping[str, Any]) -> str: + if room.get("_room_mode") == "desktop": + command = room.get("desktop_command") + state = str(command.get("state") or "") if isinstance(command, Mapping) else "" + if state == "failed": + return "needs attention" + if state in {"pending", "claimed"} and room.get("desktop_available"): + return "applying command" + return "ready" if room.get("desktop_available") else "waiting for Desktop" + if room.get("_room_mode") == "remote": + return "connected" + room_id = str(room["room_id"]) + status = service.status(room_id) + if status.get("counts", {}).get("stopping"): + return "stopping" + if status.get("blocked"): + return "needs attention" + if status.get("working"): + return "work queued or running" + state = hosted_rooms.room_state(service.db_path, room_id=room_id) + since = max(0, int(state.get("latest_seq") or 0) - 80) + recent = hosted_rooms.read_events( + service.db_path, + room_id=room_id, + since_seq=since, + limit=80, + ).get("events", []) + latest_user = max( + (int(event["seq"]) for event in recent if event.get("kind") == "message.user"), + default=0, + ) + latest_boundary = max( + ( + int(event["seq"]) + for event in recent + if event.get("kind") in {"room.activity", "room.stop_requested"} + ), + default=0, + ) + if latest_user > latest_boundary: + return "waiting for its Bots" + return "idle" + + +def _room_action_flags( + service: Any, + room: Mapping[str, Any], + *, + remote_status: Mapping[str, Any] | None = None, +) -> tuple[bool, bool]: + """Return exact Retry/Stop availability from tasks or pending commands.""" + + if room.get("_room_mode") == "desktop": + command = room.get("desktop_command") + state = str(command.get("state") or "") if isinstance(command, Mapping) else "" + action = str(command.get("action") or "") if isinstance(command, Mapping) else "" + return ( + int(room.get("desktop_failed_commands") or 0) > 0, + action == "send" and state in {"claimed", "pending"}, + ) + + status: Mapping[str, Any] + if room.get("_room_mode") == "remote": + status = remote_status or {} + else: + raw_status = service.status(str(room["room_id"])) + status = raw_status if isinstance(raw_status, Mapping) else {} + raw_counts = status.get("counts") + counts = raw_counts if isinstance(raw_counts, Mapping) else {} + raw_actions = status.get("pending_actions") + actions = raw_actions if isinstance(raw_actions, list) else [] + retryable = any( + isinstance(action_row, Mapping) and action_row.get("kind") == "retry" + for action_row in actions + ) or bool(counts.get("deferred") or counts.get("indeterminate")) + stoppable = bool(status.get("working")) and not bool(counts.get("stopping")) + return retryable, stoppable + + +def format_room_list( + service: Any, + *, + rooms: list[dict[str, Any]] | None = None, + rooms_command: str = "/group", + page: int = 1, +) -> str: + """Render a bounded, scan-friendly Group Chat list.""" + + rooms = list_messaging_rooms(service) if rooms is None else list(rooms) + if not rooms: + return "👥 **No Group Chats yet**\nCreate one in Hermes Desktop first." + rooms = sorted(rooms, key=lambda room: int(room.get("messaging_ref") or 0)) + if not isinstance(page, int) or page < 1: + raise RoomControlError("Page numbers start at 1.") + page_count = max(1, (len(rooms) + MAX_ROOM_CHOICES - 1) // MAX_ROOM_CHOICES) + if page > page_count: + raise RoomControlError(f"There are only {page_count} Group Chat pages.") + start = (page - 1) * MAX_ROOM_CHOICES + visible_rooms = rooms[start : start + MAX_ROOM_CHOICES] + heading = "Group Chats" if page_count == 1 else f"Group Chats — page {page} of {page_count}" + lines = [f"👥 **{heading}**"] + for room in visible_rooms: + name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + member_count = _room_member_count(room) + status = _room_status(service, room) + lines.append( + f"{_room_status_icon(status)} **{room_reference(room)}. " + f"{_plain_display_label(name, limit=72)}** · " + f"{status} · {member_count} Bot{'s' if member_count != 1 else ''}" + ) + if page < page_count: + lines.append(f"More: `{rooms_command} list {page + 1}`") + elif page > 1: + lines.append(f"Previous: `{rooms_command} list {page - 1}`") + lines.extend( + [ + "", + "────────", + "🧭 **Controls**", + f"Check: `{rooms_command} `", + f"Send: `{rooms_command} send `", + f"Bots: `{rooms_command} bots`", + f"Retry: `{rooms_command} retry`", + f"Stop: `{rooms_command} stop`", + ] + ) + return "\n".join(lines) + + +def _event_label(event: Mapping[str, Any], member_names: Mapping[str, str]) -> str: + raw_actor = event.get("actor") + actor: Mapping[str, Any] = raw_actor if isinstance(raw_actor, Mapping) else {} + actor_id = str(actor.get("id") or "") + display_name = _clean_line(actor.get("display_name"), limit=48) + if display_name: + return display_name + if event.get("kind") == "message.member": + raw_payload = event.get("payload") + payload: Mapping[str, Any] = ( + raw_payload if isinstance(raw_payload, Mapping) else {} + ) + member_id = str(payload.get("member_id") or actor_id) + return member_names.get(member_id, "Bot") + return "You" + + +def format_room_detail( + service: Any, + room: Mapping[str, Any], + *, + room_command: str = "/group", +) -> str: + """Render status plus the latest visible room messages.""" + + room_id = str(room["room_id"]) + if not isinstance(room.get("messaging_ref"), int): + room = next( + ( + candidate + for candidate in list_messaging_rooms(service) + if str(candidate["room_id"]) == room_id + ), + dict(room), + ) + raw_members = room.get("members") + members: list[Any] = raw_members if isinstance(raw_members, list) else [] + desktop_mode = room.get("_room_mode") == "desktop" + remote_mode = room.get("_room_mode") == "remote" + if desktop_mode: + visible = [ + event for event in room.get("log", []) if isinstance(event, Mapping) + ][-MAX_RECENT_MESSAGES:] + member_names: dict[str, str] = {} + elif remote_mode: + summary = _remote_summary(service, room) + remote_room = summary["room"] + raw_remote_members = remote_room.get("members") + members = ( + list(raw_remote_members) + if isinstance(raw_remote_members, list) + else [] + ) + room = { + **room, + "name": remote_room.get("name") or room.get("name"), + "members": members, + "member_count": len(members), + "_remote_status": summary.get("status"), + } + member_names = { + str(member.get("member_id") or ""): _clean_line( + member.get("display_name") or member.get("handle") or "Bot", + limit=48, + ) + for member in members + if isinstance(member, Mapping) + } + raw_events = summary.get("events") + visible = [ + event + for event in (raw_events if isinstance(raw_events, list) else []) + if isinstance(event, Mapping) + and event.get("kind") in {"message.user", "message.member"} + ][-MAX_RECENT_MESSAGES:] + else: + state = hosted_rooms.room_state(service.db_path, room_id=room_id) + since = max(0, int(state.get("latest_seq") or 0) - 80) + delta = hosted_rooms.read_events( + service.db_path, + room_id=room_id, + since_seq=since, + limit=80, + ) + member_names = { + str(member.get("member_id") or ""): _clean_line( + member.get("display_name") or member.get("handle") or "Bot", + limit=48, + ) + for member in members + if isinstance(member, Mapping) + } + visible = [ + event + for event in delta.get("events", []) + if isinstance(event, Mapping) + and event.get("kind") in {"message.user", "message.member"} + ][-MAX_RECENT_MESSAGES:] + name = _clean_line(room.get("name") or room_id, limit=72) + status_text = _room_status(service, room) + action_status: Mapping[str, Any] | None = None + if remote_mode: + remote_status = room.get("_remote_status") + if isinstance(remote_status, Mapping): + action_status = remote_status + raw_counts = remote_status.get("counts") + counts = raw_counts if isinstance(raw_counts, Mapping) else {} + if counts.get("stopping"): + status_text = "stopping" + elif remote_status.get("blocked"): + status_text = "needs attention" + elif remote_status.get("working"): + status_text = "work queued or running" + else: + status_text = "idle" + lines = [ + f"💬 **{_plain_display_label(name, limit=72)}**", + f"{_room_status_icon(status_text)} {status_text}", + f"👥 {len(members)} Bot{'s' if len(members) != 1 else ''}", + ] + participant_lines = _room_participant_lines(room) + if participant_lines: + lines.extend(["", "🤖 **Bots**", *participant_lines]) + if visible: + lines.extend(["", "🕘 **Recent activity**"]) + for event in visible: + if desktop_mode: + source = event.get("from") if isinstance(event.get("from"), Mapping) else {} + label = _clean_line(source.get("name") or "You", limit=48) + text = event.get("text") + else: + payload = event.get("payload") if isinstance(event.get("payload"), Mapping) else {} + label = _event_label(event, member_names) + text = payload.get("text") + lines.append( + f"• **{_plain_display_label(label, limit=48)}:** " + f"{_plain_preview_text(text)}" + ) + else: + lines.extend(["", "No messages yet."]) + failed_commands = int(room.get("desktop_failed_commands") or 0) + show_retry, show_stop = _room_action_flags( + service, + room, + remote_status=action_status, + ) + if ( + desktop_mode + and failed_commands > 0 + ): + lines.append( + ( + "The latest command could not be applied." + if failed_commands == 1 + else f"{failed_commands} commands could not be applied." + ) + + " Retry here or open this Group Chat in Hermes Desktop." + ) + lines.extend(["", "────────", "🧭 **Controls**"]) + lines.append( + f"Send: `{room_command} {room_reference(room)} send `" + ) + lines.append(f"Bots: `{room_command} {room_reference(room)} bots`") + if show_retry: + lines.append(f"Retry: `{room_command} {room_reference(room)} retry`") + if show_stop: + lines.append(f"Stop: `{room_command} {room_reference(room)} stop`") + if _room_has_targetable_handle(room): + lines.append( + f"Message one Bot: `{room_command} {room_reference(room)} send @handle `" + ) + return "\n".join(lines) + + +def messaging_actor(event: Any, *, gateway_id: str) -> dict[str, str]: + """Build a stable actor without persisting raw platform user IDs.""" + + source = getattr(event, "source", None) + platform_value = getattr(getattr(source, "platform", None), "value", None) + platform = _clean_line(platform_value or "messaging", limit=32).casefold() + raw_user_id = ( + getattr(source, "user_id_alt", None) + or getattr(event, "user_id", None) + or getattr(source, "user_id", None) + or "unknown" + ) + scope = getattr(source, "scope_id", None) or getattr(source, "guild_id", None) + chat_id = getattr(source, "chat_id", None) + digest = hashlib.sha256( + f"{gateway_id}:{platform}:{scope or ''}:{chat_id or ''}:{raw_user_id}".encode() + ).hexdigest()[:20] + raw_name = _clean_line( + getattr(event, "user_name", None) or getattr(source, "user_name", None), + limit=48, + ) + platform_label = platform.replace("_", " ").title() + display_name = ( + f"{raw_name} via {platform_label}" + if raw_name and raw_name != str(raw_user_id) + else platform_label + ) + return { + "kind": "user", + "id": f"messaging:{platform}:{digest}", + "display_name": display_name, + } + + +def _raw_transport_id(event: Any) -> Any: + """Extract one adapter-owned redelivery key without guessing from text.""" + + metadata = getattr(event, "metadata", None) + candidates: list[Any] = [] + if isinstance(metadata, Mapping): + candidates.extend( + metadata.get(key) + for key in ( + "delivery_id", + "event_id", + "message_id", + "request_id", + "update_id", + ) + ) + raw = getattr(event, "raw_message", None) + payloads = [raw] + if isinstance(raw, Mapping): + payloads.extend( + raw.get(key) for key in ("event", "message", "data", "container") + ) + for payload in payloads: + if isinstance(payload, Mapping): + candidates.extend( + payload.get(key) + for key in ( + "client_msg_id", + "trigger_id", + "event_id", + "message_id", + "id", + "ts", + "event_ts", + "timestamp_ms", + "timestamp", + ) + ) + if raw is not None and not isinstance(raw, Mapping): + candidates.extend( + getattr(raw, key, None) for key in ("id", "interaction_id") + ) + return next( + ( + value + for value in candidates + if value is not None and str(value).strip() + ), + None, + ) + + +def messaging_event_id(event: Any) -> str: + """Return a deterministic idempotency key when the transport provides one.""" + + source = getattr(event, "source", None) + platform = getattr(getattr(source, "platform", None), "value", "messaging") + stable_message_id = ( + getattr(event, "message_id", None) + or getattr(source, "message_id", None) + or _raw_transport_id(event) + or getattr(event, "platform_update_id", None) + ) + if stable_message_id is None: + raise RoomControlError( + "This channel didn’t provide a stable message ID, so Hermes can’t " + "safely repeat this room command. Try another connected channel." + ) + material = "|".join( + str(value or "") + for value in ( + platform, + getattr(source, "chat_id", None), + getattr(source, "thread_id", None), + getattr(source, "user_id_alt", None) + or getattr(event, "user_id", None) + or getattr(source, "user_id", None), + stable_message_id, + ) + ) + return f"messaging:{hashlib.sha256(material.encode()).hexdigest()}" + + +def ensure_text_only(event: Any) -> None: + """Reject media explicitly until hosted-room attachment transport exists.""" + + source = getattr(event, "source", None) + if ( + getattr(source, "message_had_attachments", False) + or getattr(event, "media_urls", None) + or getattr(event, "media_types", None) + ): + raise RoomControlError( + "Attachments from messaging chats aren’t supported yet. Send text only." + ) + + +def is_machine_authored(event: Any) -> bool: + """Recognize native and relayed bot/webhook provenance defensively.""" + + source = getattr(event, "source", None) + if getattr(source, "is_bot", False): + return True + metadata = getattr(event, "metadata", None) + if isinstance(metadata, Mapping) and any( + metadata.get(key) is True + for key in ("is_bot", "sender_is_bot", "webhook_sender") + ): + return True + raw = getattr(event, "raw_message", None) + if isinstance(raw, Mapping): + if raw.get("bot_id") or raw.get("bot_profile"): + return True + if raw.get("subtype") in {"bot_message", "webhook_message"}: + return True + for owner_field in ("author", "user"): + owner = getattr(raw, owner_field, None) + if getattr(owner, "bot", False) or getattr(owner, "is_bot", False): + return True + return False + + +def is_message_edit(event: Any) -> bool: + """Reject edited commands even when a platform redelivers them as messages.""" + + source = getattr(event, "source", None) + if getattr(source, "message_is_edit", False): + return True + metadata = getattr(event, "metadata", None) + if isinstance(metadata, Mapping) and metadata.get("message_is_edit") is True: + return True + raw = getattr(event, "raw_message", None) + if isinstance(raw, Mapping): + if raw.get("editMessage") or raw.get("isEdited") is True: + return True + if raw.get("subtype") == "message_changed": + return True + relation = raw.get("m.relates_to") + if isinstance(relation, Mapping) and relation.get("rel_type") == "m.replace": + return True + return bool(getattr(raw, "edit_date", None) or getattr(raw, "edited_at", None)) + + +def relay_provenance_is_unknown(event: Any) -> bool: + """Fail closed until a relay producer classifies the inbound author.""" + + source = getattr(event, "source", None) + if not getattr(source, "delivered_via_upstream_relay", False): + return False + metadata = getattr(event, "metadata", None) + return not ( + isinstance(metadata, Mapping) + and metadata.get("relay_author_classified") is True + and metadata.get("relay_edit_classified") is True + ) + + +def _desktop_authority_hash(room: Mapping[str, Any]) -> str: + authority_hash = str(room.get("desktop_authority_hash") or "").strip().lower() + if not re.fullmatch(r"[a-f0-9]{64}", authority_hash): + raise RoomControlError( + "Open this Group Chat once in the latest Hermes Desktop, then try again." + ) + return authority_hash + + +def send_to_room(service: Any, room: Mapping[str, Any], event: Any, text: str) -> str: + """Append or hand off one idempotent room turn.""" + + ensure_text_only(event) + event_id = messaging_event_id(event) + name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + if room.get("_room_mode") == "desktop": + from gateway.desktop_room_mailbox import enqueue_command, default_db_path + + actor = messaging_actor( + event, + gateway_id=hosted_rooms.local_authority_gateway_id(), + ) + enqueue_command( + default_db_path(), + command_id=event_id, + room_id=str(room["room_id"]), + authority_hash=_desktop_authority_hash(room), + action="send", + payload={ + "message": text, + "actor_display_name": actor.get("display_name") or "Messaging", + "recipients": _frozen_desktop_recipients(room), + }, + ) + if room.get("desktop_available"): + return f"Queued in {name}." + return f"Saved for {name}. Open or update Hermes Desktop to continue." + if room.get("_room_mode") == "remote": + actor = messaging_actor( + event, + gateway_id=hosted_rooms.local_authority_gateway_id(), + ) + _remote_mutate( + service, + room, + action="send", + command_id=event_id, + text=text, + actor_display_name=actor.get("display_name") or "Messaging", + ) + return f"Queued in {name}." + service.send( + room_id=str(room["room_id"]), + event_id=event_id, + payload={"text": text, "thread_id": event_id}, + actor=messaging_actor( + event, + gateway_id=str(room["authority_gateway_id"]), + ), + ) + return f"Queued in {name}." + + +def stop_room(service: Any, room: Mapping[str, Any], event: Any) -> str: + """Cancel active room tasks using a transport-derived idempotency key.""" + + cancel_id = f"stop:{messaging_event_id(event)}" + name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + if room.get("_room_mode") == "desktop": + from gateway.desktop_room_mailbox import ( + enqueue_command, + default_db_path, + latest_command_states, + ) + + room_id = str(room["room_id"]) + current = room.get("desktop_command") or latest_command_states( + default_db_path(), + [room_id], + ).get(room_id) + target_command_id = ( + str(current.get("command_id") or "") + if isinstance(current, Mapping) + and current.get("action") == "send" + and current.get("state") in {"claimed", "pending"} + else "" + ) + target_thread_id = _latest_projected_thread(room) + enqueue_command( + default_db_path(), + command_id=cancel_id, + room_id=room_id, + authority_hash=_desktop_authority_hash(room), + action="stop", + payload={ + **({"target_command_id": target_command_id} if target_command_id else {}), + **({"target_thread_id": target_thread_id} if target_thread_id else {}), + }, + ) + if room.get("desktop_available"): + return f"Stop requested for {name}." + return f"Stop saved for {name}. Open or update Hermes Desktop to apply it." + if room.get("_room_mode") == "remote": + _remote_mutate( + service, + room, + action="stop", + command_id=cancel_id, + ) + return f"Stop requested for {name}. Active work will stop safely." + service.stop_room(str(room["room_id"]), cancel_id=cancel_id) + return f"Stop requested for {name}. Active work will stop safely." + + +def retry_room(service: Any, room: Mapping[str, Any], event: Any) -> str: + """Retry bounded failed work after an explicit owner command.""" + + name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + room_id = str(room["room_id"]) + command_id = f"retry:{messaging_event_id(event)}" + actor = messaging_actor( + event, + gateway_id=( + hosted_rooms.local_authority_gateway_id() + if room.get("_room_mode") == "desktop" + else str(room.get("authority_gateway_id") or "") + ), + ) + receipt_db = Path(service.db_path) + if room.get("_room_mode") == "remote": + result = _remote_mutate( + service, + room, + action="retry", + command_id=command_id, + actor_display_name=actor.get("display_name") or "Messaging", + ) + retried = result.get("retried") + if not isinstance(retried, int): + processed = result.get("processed") + if isinstance(processed, int): + suffix = "task" if processed == 1 else "tasks" + return f"Retry checked for {name} ({processed} {suffix})." + summary = result.get("summary") + retried = ( + int(summary.get("retried") or 0) + if isinstance(summary, Mapping) + else 0 + ) + suffix = "task" if retried == 1 else "tasks" + return f"Retry queued for {name} ({retried} {suffix})." + if room.get("_room_mode") == "desktop": + from gateway.desktop_room_mailbox import ( + default_db_path, + retry_failed_commands, + retryable_command_ids, + ) + + mailbox_db = default_db_path() + try: + frozen, completed = _retry_receipt_plan( + receipt_db, + command_id=command_id, + room_id=room_id, + actor=actor, + task_ids=[], + ) + except RoomControlError as exc: + if str(exc) != "This Group Chat has no failed work to retry.": + raise + target_ids = retryable_command_ids( + mailbox_db, + room_id=room_id, + ) + frozen, completed = _retry_receipt_plan( + receipt_db, + command_id=command_id, + room_id=room_id, + actor=actor, + task_ids=[target_id for target_id in target_ids if target_id], + ) + if completed: + return completed + retried = retry_failed_commands( + mailbox_db, + room_id=room_id, + command_ids=frozen, + ) + count = len(retried) + noun = "command" if count == 1 else "commands" + result = f"Retry queued for {name} ({count} {noun})." + _complete_retry_receipt(receipt_db, command_id=command_id, result=result) + return result + + pending = [ + action + for action in service.status(room_id).get("pending_actions", []) + if isinstance(action, Mapping) + and action.get("kind") == "retry" + and str(action.get("task_id") or "") + ] + frozen, completed = _retry_receipt_plan( + receipt_db, + command_id=command_id, + room_id=room_id, + actor=actor, + task_ids=[str(action["task_id"]) for action in pending[:MAX_ROOM_CHOICES]], + ) + if completed: + return completed + + def queue_for_worker() -> None: + hosted_room_controls.begin_control_retry( + receipt_db, + command_id=f"worker:{command_id}", + room_id=room_id, + member_id=str(actor.get("id") or "messaging"), + task_ids=list(frozen), + ) + + for task_id in frozen: + try: + service.retry_room_task( + room_id, + task_id=task_id, + retry_id=hosted_room_controls.control_retry_attempt_id( + command_id, + task_id, + ), + ) + except driver.LeaseHeldError: + queue_for_worker() + break + except RoomControlError as exc: + if str(exc) != ( + "Retry is available when the device running this Group Chat is online." + ): + raise + queue_for_worker() + break + except Exception: + still_pending = { + str(action.get("task_id") or "") + for action in service.status(room_id).get("pending_actions", []) + if isinstance(action, Mapping) and action.get("kind") == "retry" + } + if task_id in still_pending: + queue_for_worker() + break + count = len(frozen) + suffix = "task" if count == 1 else "tasks" + result = f"Retry queued for {name} ({count} {suffix})." + _complete_retry_receipt(receipt_db, command_id=command_id, result=result) + return result diff --git a/gateway/hosted_room_peer.py b/gateway/hosted_room_peer.py index 5cbc102e07544..581066b5008aa 100644 --- a/gateway/hosted_room_peer.py +++ b/gateway/hosted_room_peer.py @@ -814,8 +814,13 @@ def decode_room_grant( *, permission: str, now: float | None = None, + allow_expired_for_revocation: bool = False, ) -> dict[str, Any]: """Verify grant signature, lifetime and operation without a dispatch.""" + if allow_expired_for_revocation and permission != "status": + raise HostedRoomGrantError( + "expired room grants may only be decoded for revocation" + ) if not isinstance(token, str) or len(token.encode("utf-8")) > MAX_TOKEN_BYTES: raise HostedRoomGrantError("room grant is invalid") encoded_token, separator, signature_token = token.partition(".") @@ -856,7 +861,9 @@ def decode_room_grant( if permission in {"approve", "status", "stop"} else expires_at ) - if checked_now < issued_at - 30 or checked_now >= operation_expires_at: + if checked_now < issued_at - 30 or ( + checked_now >= operation_expires_at and not allow_expired_for_revocation + ): raise HostedRoomGrantError("room grant is expired or not active") permissions = payload.get("permissions") if not isinstance(permissions, list) or permission not in permissions: diff --git a/gateway/hosted_room_policy_checkpoint.py b/gateway/hosted_room_policy_checkpoint.py index e141b0e158e2a..b9f474573ac20 100644 --- a/gateway/hosted_room_policy_checkpoint.py +++ b/gateway/hosted_room_policy_checkpoint.py @@ -632,18 +632,27 @@ def events_for_task( WHERE room_id=? AND seq=?""", (room_id, source_event_seq), ).fetchone() + if source is None: + source = conn.execute( + """SELECT thread_id + FROM hosted_room_policy_transcript + WHERE room_id=? AND seq=?""", + (room_id, source_event_seq), + ).fetchone() + active_rows = [] + else: + active_rows = conn.execute( + """SELECT event_json FROM hosted_room_policy_events + WHERE room_id=? AND discussion_event_id=? + ORDER BY seq LIMIT ?""", + ( + room_id, + str(source["discussion_event_id"]), + MAX_ACTIVE_POLICY_EVENTS + 1, + ), + ).fetchall() if source is None: return [] - active_rows = conn.execute( - """SELECT event_json FROM hosted_room_policy_events - WHERE room_id=? AND discussion_event_id=? - ORDER BY seq LIMIT ?""", - ( - room_id, - str(source["discussion_event_id"]), - MAX_ACTIVE_POLICY_EVENTS + 1, - ), - ).fetchall() transcript_events = self._transcript_events( conn, room_id=room_id, diff --git a/gateway/hosted_room_replicas.py b/gateway/hosted_room_replicas.py index 26b87080eda0e..ac7494d68159d 100644 --- a/gateway/hosted_room_replicas.py +++ b/gateway/hosted_room_replicas.py @@ -1,50 +1,49 @@ -"""Replica store and takeover primitives for hosted Group Chat rooms. +"""Passive replica store for hosted Group Chat rooms. The authority gateway owns a room's ordered log in ``gateway/hosted_rooms.py``. This module gives every OTHER participant gateway a durable local copy of that -log, and the fenced primitives to continue the room when the authority host -dies: - -- ``ingest_page()`` persists replay pages (``groups.log`` output, which carries - the room's authority stamp) idempotently, refusing sequence gaps and - authority-epoch regressions. -- ``promote_replica()`` instantiates the replicated log as a locally-owned - hosted room at ``epoch + 1`` with a lineage-proving ``authority.claimed`` - event, so a surviving participant can resume the room. -- ``demote_room()`` fences a returning stale authority: presented with proof of - a newer epoch, the local room records ``authority.lost`` and stops being - authoritative. - -Storage primitives only: none of these decide *when* takeover is safe. The -caller (an explicit user action today; a lease/quorum driver later) must -establish that the previous owner can no longer commit before promoting. +log: + +``ingest_page()`` persists ``groups.log`` replay pages idempotently while +refusing gaps, conflicting overlap, forged authority changes, and resurrection +after a terminal disband event. A replica deliberately remains passive: safe +takeover needs a globally exclusive lease or quorum, which this storage layer +does not provide. """ from __future__ import annotations import json +import math import sqlite3 import time +from contextlib import contextmanager from pathlib import Path from typing import Any from gateway.hosted_rooms import ( MAX_ACTOR_ID_CHARS, + MAX_EVENT_ID_CHARS, MAX_EVENT_JSON_BYTES, + MAX_GATEWAY_EVENT_BYTES, + MAX_LOG_LIMIT, + MAX_LOG_PAGE_BYTES, MAX_ROOM_ID_CHARS, HostedRoomError, - RoomConflictError, _canonical_json, _connect, + _prune_disbanded_replicas_locked, + _prune_disbanded_rooms_locked, _transaction, + _validate_actor, + _validate_event_kind, _validate_identifier, _validate_members, _validate_room_name, - local_authority_gateway_id, ) MAX_REPLICA_ROOMS = 256 -MAX_REPLICA_EVENT_BYTES = 256 * 1024 * 1024 +MAX_REPLICA_EVENT_BYTES = MAX_GATEWAY_EVENT_BYTES class ReplicaError(HostedRoomError): @@ -55,8 +54,16 @@ class ReplicaGapError(ReplicaError): """A page does not start at the replica's next expected sequence.""" -class ReplicaEpochRegressionError(ReplicaError): - """A page or demotion carries an older authority epoch than stored.""" +class ReplicaHistoryExpiredError(ReplicaError): + """A compacted replica keeps its identity but no longer has replay data.""" + + reason = "replica_history_expired" + + +class ReplicaLineageUnverifiedError(ReplicaError): + """A replica cannot prove the complete authority lineage it was given.""" + + reason = "replica_lineage_unverified" def _initialize_replica_schema(conn: sqlite3.Connection) -> None: @@ -71,7 +78,10 @@ def _initialize_replica_schema(conn: sqlite3.Connection) -> None: latest_seq INTEGER NOT NULL DEFAULT 0, event_bytes INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, - updated_at REAL NOT NULL + updated_at REAL NOT NULL, + disbanded_at REAL, + quarantined_at REAL, + quarantine_reason TEXT )""" ) conn.execute( @@ -87,19 +97,134 @@ def _initialize_replica_schema(conn: sqlite3.Connection) -> None: PRIMARY KEY (room_id, seq) )""" ) + columns = { + str(row["name"]) + for row in conn.execute("PRAGMA table_info(hosted_room_replicas)") + } + if "disbanded_at" not in columns: + conn.execute("ALTER TABLE hosted_room_replicas ADD COLUMN disbanded_at REAL") + if "quarantined_at" not in columns: + conn.execute("ALTER TABLE hosted_room_replicas ADD COLUMN quarantined_at REAL") + if "quarantine_reason" not in columns: + conn.execute("ALTER TABLE hosted_room_replicas ADD COLUMN quarantine_reason TEXT") + conn.execute( + """UPDATE hosted_room_replicas + SET disbanded_at=( + SELECT MIN(created_at) + FROM hosted_room_replica_events + WHERE hosted_room_replica_events.room_id = + hosted_room_replicas.room_id + AND kind='room.disbanded' + ) + WHERE disbanded_at IS NULL + AND EXISTS ( + SELECT 1 FROM hosted_room_replica_events + WHERE hosted_room_replica_events.room_id = + hosted_room_replicas.room_id + AND kind='room.disbanded' + )""" + ) -def _replica_transaction(db_path: Path | str): - return _transaction(db_path, immediate=True) +def _audit_existing_replicas_locked(conn: sqlite3.Connection) -> None: + """Quarantine lineage written by the pre-fix replica implementation.""" + for row in conn.execute( + """SELECT room_id, authority_gateway_id, authority_epoch, last_seq, + latest_seq, event_bytes, disbanded_at, quarantine_reason + FROM hosted_room_replicas""" + ).fetchall(): + room_id = str(row["room_id"]) + events = conn.execute( + """SELECT seq, event_id, authority_epoch, kind, actor_json, + payload_json, created_at + FROM hosted_room_replica_events + WHERE room_id=? ORDER BY seq""", + (room_id,), + ).fetchall() + reasons: list[str] = [] + seqs = [int(event["seq"]) for event in events] + event_ids = [str(event["event_id"]) for event in events] + last_seq = int(row["last_seq"]) + latest_seq = int(row["latest_seq"]) + if int(row["authority_epoch"]) != 1: + reasons.append("unverified_authority_epoch") + if seqs != list(range(1, last_seq + 1)): + reasons.append("non_contiguous_history") + if len(set(event_ids)) != len(event_ids): + reasons.append("duplicate_event_id") + if latest_seq < last_seq: + reasons.append("coverage_regression") + disband_positions = [ + index for index, event in enumerate(events) + if event["kind"] == "room.disbanded" + ] + if disband_positions and disband_positions != [len(events) - 1]: + reasons.append("events_after_disband") + if disband_positions and last_seq != latest_seq: + reasons.append("incomplete_terminal_history") + if any( + event["authority_epoch"] != int(row["authority_epoch"]) + for event in events + ): + reasons.append("mixed_authority_lineage") + try: + _validate_identifier( + row["authority_gateway_id"], + label="authority_gateway_id", + max_chars=MAX_ACTOR_ID_CHARS, + ) + for event in events: + kind = _validate_event_kind(event["kind"]) + _validate_identifier( + event["event_id"], + label="event_id", + max_chars=MAX_EVENT_ID_CHARS, + ) + actor, _ = _validate_actor( + json.loads(event["actor_json"]), kind=kind + ) + if ( + actor["kind"] == "gateway" + and actor["id"] != str(row["authority_gateway_id"]) + ): + reasons.append("gateway_actor_authority_mismatch") + payload = json.loads(event["payload_json"]) + if not isinstance(payload, dict): + raise ReplicaError("event payload is not an object") + if not math.isfinite(float(event["created_at"])): + raise ReplicaError("event timestamp is not finite") + except (HostedRoomError, TypeError, ValueError, json.JSONDecodeError): + reasons.append("invalid_event_shape") + + recomputed_bytes = sum( + len(str(event["event_id"]).encode("utf-8")) + + len(str(event["kind"]).encode("utf-8")) + + len(str(event["actor_json"]).encode("utf-8")) + + len(str(event["payload_json"]).encode("utf-8")) + for event in events + ) + if recomputed_bytes != int(row["event_bytes"]): + conn.execute( + "UPDATE hosted_room_replicas SET event_bytes=? WHERE room_id=?", + (recomputed_bytes, room_id), + ) + if reasons and row["quarantine_reason"] is None: + conn.execute( + """UPDATE hosted_room_replicas + SET quarantined_at=?, quarantine_reason=? + WHERE room_id=?""", + (time.time(), reasons[0], room_id), + ) -def _ensure_schema(db_path: Path | str) -> None: - conn = _connect(db_path) - try: - with conn: - _initialize_replica_schema(conn) - finally: - conn.close() +@contextmanager +def _replica_transaction(db_path: Path | str): + with _transaction(db_path, immediate=True) as conn: + _initialize_replica_schema(conn) + # A still-running #99047 process can write after migration. Re-audit + # inside the same write transaction before every read or extension. + _audit_existing_replicas_locked(conn) + yield conn def _event_bytes(event: dict[str, Any]) -> int: @@ -119,13 +244,24 @@ def _event_bytes(event: dict[str, Any]) -> int: ) -def _validate_page(page: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: +def _validate_non_negative_int(value: Any, *, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ReplicaError(f"{label} must be a non-negative integer") + return value + + +def _validate_page( + page: Any, +) -> tuple[list[dict[str, Any]], dict[str, Any], int, int, bool]: if not isinstance(page, dict): raise ReplicaError("page must be an object") + _canonical_json(page, label="page", max_bytes=MAX_LOG_PAGE_BYTES) events = page.get("events") authority = page.get("authority") if not isinstance(events, list): raise ReplicaError("page.events must be a list") + if len(events) > MAX_LOG_LIMIT: + raise ReplicaError(f"page.events cannot exceed {MAX_LOG_LIMIT} events") if not isinstance(authority, dict): raise ReplicaError("page.authority is required for replication") gateway_id = _validate_identifier( @@ -136,6 +272,20 @@ def _validate_page(page: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: epoch = authority.get("epoch") if isinstance(epoch, bool) or not isinstance(epoch, int) or epoch < 1: raise ReplicaError("page.authority.epoch must be a positive integer") + cursor = _validate_non_negative_int(page.get("cursor"), label="page.cursor") + latest_seq = _validate_non_negative_int( + page.get("latest_seq"), label="page.latest_seq" + ) + has_more = page.get("has_more") + if not isinstance(has_more, bool): + raise ReplicaError("page.has_more must be a boolean") + if cursor > latest_seq: + raise ReplicaError("page.cursor cannot exceed page.latest_seq") + if has_more != (cursor < latest_seq): + raise ReplicaError("page.has_more does not match its replay cursor") + + normalized_events: list[dict[str, Any]] = [] + event_ids: set[str] = set() previous_seq: int | None = None for event in events: if not isinstance(event, dict): @@ -146,14 +296,69 @@ def _validate_page(page: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: if previous_seq is not None and seq != previous_seq + 1: raise ReplicaGapError("page events must be contiguous") previous_seq = seq - for field in ("event_id", "kind"): - if not isinstance(event.get(field), str) or not event[field]: - raise ReplicaError(f"event.{field} must be a non-empty string") - if not isinstance(event.get("actor"), dict): - raise ReplicaError("event.actor must be an object") - if "payload" not in event: - raise ReplicaError("event.payload is required") - return events, {"gateway_id": gateway_id, "epoch": epoch} + event_room_id = _validate_identifier( + event.get("room_id"), + label="event.room_id", + max_chars=MAX_ROOM_ID_CHARS, + ) + event_id = _validate_identifier( + event.get("event_id"), + label="event.event_id", + max_chars=MAX_EVENT_ID_CHARS, + ) + if event_id in event_ids: + raise ReplicaError("page repeats an event_id") + event_ids.add(event_id) + kind = _validate_event_kind(event.get("kind")) + actor, actor_json = _validate_actor(event.get("actor"), kind=kind) + if actor["kind"] == "gateway" and actor["id"] != gateway_id: + raise ReplicaError("gateway actor does not match page authority") + payload = event.get("payload") + if not isinstance(payload, dict): + raise ReplicaError("event.payload must be an object") + payload_json = _canonical_json( + payload, label="payload", max_bytes=MAX_EVENT_JSON_BYTES + ) + event_epoch = event.get("authority_epoch") + if ( + isinstance(event_epoch, bool) + or not isinstance(event_epoch, int) + or event_epoch < 1 + or event_epoch > epoch + ): + raise ReplicaError("event.authority_epoch is outside the page lineage") + created_at = event.get("created_at") + if ( + isinstance(created_at, bool) + or not isinstance(created_at, (int, float)) + or not math.isfinite(float(created_at)) + ): + raise ReplicaError("event.created_at must be a finite number") + normalized_events.append( + { + "room_id": event_room_id, + "seq": seq, + "event_id": event_id, + "kind": kind, + "actor": actor, + "actor_json": actor_json, + "authority_epoch": event_epoch, + "payload": payload, + "payload_json": payload_json, + "created_at": float(created_at), + } + ) + if normalized_events and normalized_events[-1]["seq"] != cursor: + raise ReplicaError("page.cursor must equal the last returned sequence") + if not normalized_events and cursor != latest_seq: + raise ReplicaError("an incomplete replay page must include events") + return ( + normalized_events, + {"gateway_id": gateway_id, "epoch": epoch}, + cursor, + latest_seq, + has_more, + ) def ingest_page( @@ -176,19 +381,43 @@ def ingest_page( ) room_name = _validate_room_name(room_name) _, members_json = _validate_members(members) - events, authority = _validate_page(page) + events, authority, _cursor, latest_seq, _has_more = _validate_page(page) + for event in events: + if event["room_id"] != room_id: + raise ReplicaError("page contains an event for a different room") now = time.time() if now is None else float(now) - _ensure_schema(db_path) - with _replica_transaction(db_path) as conn: - _initialize_replica_schema(conn) + _prune_disbanded_replicas_locked(conn, now=now) + if conn.execute( + "SELECT 1 FROM hosted_rooms WHERE room_id=?", (room_id,) + ).fetchone(): + raise ReplicaError("room_id is already locally authoritative") + if conn.execute( + "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", (room_id,) + ).fetchone(): + raise ReplicaError("room_id is permanently retired on this gateway") row = conn.execute( - """SELECT authority_gateway_id, authority_epoch, last_seq, - latest_seq, event_bytes + """SELECT name, members_json, authority_gateway_id, authority_epoch, + last_seq, latest_seq, event_bytes, disbanded_at, + quarantined_at, quarantine_reason FROM hosted_room_replicas WHERE room_id=?""", (room_id,), ).fetchone() if row is None: + _prune_disbanded_replicas_locked( + conn, + now=None, + max_replica_rooms=max(0, MAX_REPLICA_ROOMS - 1), + ) + reservation = conn.execute( + """SELECT owner_kind FROM hosted_room_id_reservations + WHERE room_id=?""", + (room_id,), + ).fetchone() + if reservation is not None and reservation["owner_kind"] == "replica": + raise ReplicaHistoryExpiredError( + "replica history expired; room_id remains permanently retired" + ) count = conn.execute( "SELECT COUNT(*) FROM hosted_room_replicas" ).fetchone()[0] @@ -196,34 +425,116 @@ def ingest_page( raise ReplicaError("replica room capacity exhausted") stored_epoch = 0 last_seq = 0 - stored_bytes = 0 + stored_latest = 0 + disbanded_at = None + if authority["epoch"] != 1: + raise ReplicaLineageUnverifiedError( + "replica lineage is incomplete; the first authority epoch is required" + ) else: stored_epoch = int(row["authority_epoch"]) last_seq = int(row["last_seq"]) - stored_bytes = int(row["event_bytes"]) - - if authority["epoch"] < stored_epoch: - raise ReplicaEpochRegressionError( - "page authority epoch is older than the stored replica epoch" - ) + stored_latest = int(row["latest_seq"]) + disbanded_at = row["disbanded_at"] + if row["quarantine_reason"] is not None: + raise ReplicaError( + "stored replica is quarantined: " + str(row["quarantine_reason"]) + ) + if row["name"] != room_name or row["members_json"] != members_json: + raise ReplicaError("replica metadata conflicts with stored state") + if ( + row["authority_gateway_id"] != authority["gateway_id"] + or stored_epoch != authority["epoch"] + ): + raise ReplicaLineageUnverifiedError( + "replica authority changed without a verified takeover lineage" + ) + if latest_seq < stored_latest: + raise ReplicaError("page.latest_seq regresses stored replica coverage") + + for event in events: + if event["authority_epoch"] != stored_epoch and row is not None: + raise ReplicaError("event authority conflicts with stored replica lineage") + existing = conn.execute( + """SELECT seq, event_id, kind, actor_json, authority_epoch, + payload_json, created_at + FROM hosted_room_replica_events + WHERE room_id=? AND (seq=? OR event_id=?)""", + (room_id, int(event["seq"]), event["event_id"]), + ).fetchall() + for stored in existing: + if ( + int(stored["seq"]) != int(event["seq"]) + or stored["event_id"] != event["event_id"] + or stored["kind"] != event["kind"] + or stored["actor_json"] != event["actor_json"] + or stored["authority_epoch"] != event["authority_epoch"] + or stored["payload_json"] != event["payload_json"] + or float(stored["created_at"]) != event["created_at"] + ): + raise ReplicaError("replayed event conflicts with stored history") + if int(event["seq"]) <= last_seq and not existing: + raise ReplicaError("stored replica history is incomplete") new_events = [e for e in events if int(e["seq"]) > last_seq] if new_events and int(new_events[0]["seq"]) != last_seq + 1: raise ReplicaGapError( "page skips sequences the replica has not stored" ) - added_bytes = 0 - for event in new_events: - size = _event_bytes(event) - if stored_bytes + added_bytes + size > MAX_REPLICA_EVENT_BYTES: - raise ReplicaError("replica event storage exhausted") - actor_json = _canonical_json( - event["actor"], label="actor", max_bytes=4 * 1024 + if disbanded_at is not None and new_events: + raise ReplicaError("a disbanded Group Chat cannot accept later events") + disband_indexes = [ + index for index, event in enumerate(new_events) + if event["kind"] == "room.disbanded" + ] + if disband_indexes and disband_indexes != [len(new_events) - 1]: + raise ReplicaError("room.disbanded must be the terminal event") + if disband_indexes and int(new_events[-1]["seq"]) != latest_seq: + raise ReplicaError("room.disbanded must complete the source history") + + event_sizes = [_event_bytes(event) for event in new_events] + added_bytes = sum(event_sizes) + gateway_bytes = int( + conn.execute( + """SELECT + COALESCE((SELECT SUM(event_bytes) FROM hosted_rooms), 0) + + COALESCE((SELECT SUM(event_bytes) + FROM hosted_room_replicas), 0)""" + ).fetchone()[0] + ) + if gateway_bytes + added_bytes > MAX_REPLICA_EVENT_BYTES: + replica_bytes = int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_room_replicas" + ).fetchone()[0] + ) + _prune_disbanded_rooms_locked( + conn, + now=None, + max_gateway_event_bytes=max( + 0, MAX_REPLICA_EVENT_BYTES - added_bytes - replica_bytes + ), + ) + hosted_bytes = int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" + ).fetchone()[0] ) - payload_json = _canonical_json( - event["payload"], label="payload", max_bytes=MAX_EVENT_JSON_BYTES + _prune_disbanded_replicas_locked( + conn, + now=None, + max_replica_event_bytes=max( + 0, MAX_REPLICA_EVENT_BYTES - added_bytes - hosted_bytes + ), ) - epoch_value = event.get("authority_epoch") + gateway_bytes = hosted_bytes + int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_room_replicas" + ).fetchone()[0] + ) + if gateway_bytes + added_bytes > MAX_REPLICA_EVENT_BYTES: + raise ReplicaError("replica event storage exhausted") + for event in new_events: conn.execute( """INSERT INTO hosted_room_replica_events (room_id, seq, event_id, kind, actor_json, authority_epoch, @@ -234,24 +545,23 @@ def ingest_page( int(event["seq"]), event["event_id"], event["kind"], - actor_json, - epoch_value, - payload_json, - float(event.get("created_at") or now), + event["actor_json"], + event["authority_epoch"], + event["payload_json"], + event["created_at"], ), ) - added_bytes += size new_last = int(new_events[-1]["seq"]) if new_events else last_seq - latest_seq = page.get("latest_seq") - if isinstance(latest_seq, bool) or not isinstance(latest_seq, int): - latest_seq = new_last + terminal_at = ( + new_events[-1]["created_at"] if disband_indexes else disbanded_at + ) if row is None: conn.execute( """INSERT INTO hosted_room_replicas (room_id, name, members_json, authority_gateway_id, authority_epoch, last_seq, latest_seq, event_bytes, - created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + created_at, updated_at, disbanded_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( room_id, room_name, @@ -259,28 +569,25 @@ def ingest_page( authority["gateway_id"], authority["epoch"], new_last, - max(latest_seq, new_last), + latest_seq, added_bytes, now, now, + terminal_at, ), ) else: conn.execute( """UPDATE hosted_room_replicas - SET name=?, members_json=?, authority_gateway_id=?, - authority_epoch=?, last_seq=?, latest_seq=?, - event_bytes=event_bytes+?, updated_at=? + SET last_seq=?, latest_seq=?, event_bytes=event_bytes+?, + updated_at=?, disbanded_at=? WHERE room_id=?""", ( - room_name, - members_json, - authority["gateway_id"], - authority["epoch"], new_last, - max(latest_seq, new_last), + latest_seq, added_bytes, now, + terminal_at, room_id, ), ) @@ -289,7 +596,7 @@ def ingest_page( "stored_seq": new_last, "ingested": len(new_events), "authority": authority, - "caught_up": new_last >= max(latest_seq, new_last), + "caught_up": new_last >= latest_seq, } @@ -298,17 +605,29 @@ def replica_state(db_path: Path | str, *, room_id: Any) -> dict[str, Any]: room_id = _validate_identifier( room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS ) - _ensure_schema(db_path) with _replica_transaction(db_path) as conn: - _initialize_replica_schema(conn) row = conn.execute( """SELECT room_id, name, members_json, authority_gateway_id, authority_epoch, last_seq, latest_seq, event_bytes, - created_at, updated_at + created_at, updated_at, disbanded_at, + quarantined_at, quarantine_reason FROM hosted_room_replicas WHERE room_id=?""", (room_id,), ).fetchone() + reservation = ( + conn.execute( + """SELECT owner_kind FROM hosted_room_id_reservations + WHERE room_id=?""", + (room_id,), + ).fetchone() + if row is None + else None + ) if row is None: + if reservation is not None and reservation["owner_kind"] == "replica": + raise ReplicaHistoryExpiredError( + "replica history expired; room_id remains permanently retired" + ) raise ReplicaError("replica not found") return { "room_id": row["room_id"], @@ -323,248 +642,11 @@ def replica_state(db_path: Path | str, *, room_id: Any) -> dict[str, Any]: "event_bytes": int(row["event_bytes"]), "created_at": float(row["created_at"]), "updated_at": float(row["updated_at"]), - } - - -def promote_replica( - db_path: Path | str, - *, - room_id: Any, - reason: Any = "authority-unreachable", - now: float | None = None, -) -> dict[str, Any]: - """Continue a replicated room on THIS gateway at ``epoch + 1``. - - Copies the replica's log into the authoritative store, appends a lineage- - proving ``authority.claimed`` event, and returns the new room state. The - old authority is fenced everywhere the claim replicates: its epoch is now - stale and every fenced primitive rejects it. - - The caller decides that takeover is safe (the previous owner can no longer - commit). This primitive only makes the takeover atomic and provable. - """ - room_id = _validate_identifier( - room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS - ) - if not isinstance(reason, str) or not reason or len(reason) > 200: - raise ReplicaError("reason must be a non-empty string of at most 200 chars") - now = time.time() if now is None else float(now) - local_gateway = local_authority_gateway_id() - _ensure_schema(db_path) - - with _replica_transaction(db_path) as conn: - _initialize_replica_schema(conn) - replica = conn.execute( - """SELECT room_id, name, members_json, authority_gateway_id, - authority_epoch, last_seq, event_bytes - FROM hosted_room_replicas WHERE room_id=?""", - (room_id,), - ).fetchone() - if replica is None: - raise ReplicaError("replica not found") - if replica["authority_gateway_id"] == local_gateway: - raise ReplicaError("this gateway already holds the room authority") - if conn.execute( - "SELECT 1 FROM hosted_rooms WHERE room_id=?", (room_id,) - ).fetchone(): - raise RoomConflictError( - "room_id already exists in the local authoritative store" - ) - if conn.execute( - "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", - (room_id,), - ).fetchone(): - raise RoomConflictError("room_id belongs to a disbanded room") - - previous_gateway = str(replica["authority_gateway_id"]) - previous_epoch = int(replica["authority_epoch"]) - target_epoch = previous_epoch + 1 - last_seq = int(replica["last_seq"]) - claim_seq = last_seq + 1 - claim_event_id = f"system:authority-claimed:{target_epoch}" - claim_actor_json = _canonical_json( - {"kind": "system", "id": "authority-control"}, - label="actor", - max_bytes=4 * 1024, - ) - claim_payload_json = _canonical_json( - { - "previous_gateway_id": previous_gateway, - "authority_gateway_id": local_gateway, - "authority_epoch": target_epoch, - "promoted_from_replica": True, - "reason": reason, - }, - label="payload", - max_bytes=MAX_EVENT_JSON_BYTES, - ) - claim_bytes = ( - len(claim_event_id.encode("utf-8")) - + len(b"authority.claimed") - + len(claim_actor_json.encode("utf-8")) - + len(claim_payload_json.encode("utf-8")) - ) - - conn.execute( - """INSERT INTO hosted_rooms - (room_id, name, members_json, authority_gateway_id, - authority_epoch, next_seq, event_bytes, revision, - created_at, updated_at, disbanded_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, NULL)""", - ( - room_id, - replica["name"], - replica["members_json"], - local_gateway, - target_epoch, - claim_seq + 1, - int(replica["event_bytes"]) + claim_bytes, - now, - now, - ), - ) - conn.execute( - """INSERT INTO hosted_room_events - (room_id, seq, event_id, kind, actor_json, authority_epoch, - payload_json, created_at) - SELECT room_id, seq, event_id, kind, actor_json, - authority_epoch, payload_json, created_at - FROM hosted_room_replica_events WHERE room_id=?""", - (room_id,), - ) - conn.execute( - """INSERT INTO hosted_room_events - (room_id, seq, event_id, kind, actor_json, authority_epoch, - payload_json, created_at) - VALUES (?, ?, ?, 'authority.claimed', ?, ?, ?, ?)""", - ( - room_id, - claim_seq, - claim_event_id, - claim_actor_json, - target_epoch, - claim_payload_json, - now, - ), - ) - conn.execute( - "DELETE FROM hosted_room_replica_events WHERE room_id=?", (room_id,) - ) - conn.execute( - "DELETE FROM hosted_room_replicas WHERE room_id=?", (room_id,) - ) - return { - "room_id": room_id, - "authority_gateway_id": local_gateway, - "authority_epoch": target_epoch, - "previous_gateway_id": previous_gateway, - "previous_epoch": previous_epoch, - "claim_seq": claim_seq, - "latest_seq": claim_seq, - } - - -def demote_room( - db_path: Path | str, - *, - room_id: Any, - observed_gateway_id: Any, - observed_epoch: Any, - now: float | None = None, -) -> dict[str, Any]: - """Fence THIS gateway's stale room authority against a proven newer epoch. - - Called when a returning gateway observes (via a replicated - ``authority.claimed`` event or a transport rejection) that another gateway - now owns the room at a higher epoch. Appends ``authority.lost`` and adopts - the observed lineage so no further local sends can be committed at the - stale epoch. Idempotent for repeated observations of the same lineage. - """ - room_id = _validate_identifier( - room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS - ) - observed_gateway_id = _validate_identifier( - observed_gateway_id, - label="observed_gateway_id", - max_chars=MAX_ACTOR_ID_CHARS, - ) - if ( - isinstance(observed_epoch, bool) - or not isinstance(observed_epoch, int) - or observed_epoch < 1 - ): - raise ReplicaError("observed_epoch must be a positive integer") - now = time.time() if now is None else float(now) - local_gateway = local_authority_gateway_id() - - with _replica_transaction(db_path) as conn: - row = conn.execute( - """SELECT authority_gateway_id, authority_epoch, next_seq - FROM hosted_rooms WHERE room_id=? AND disbanded_at IS NULL""", - (room_id,), - ).fetchone() - if row is None: - raise ReplicaError("room not found in the local authoritative store") - current_gateway = str(row["authority_gateway_id"]) - current_epoch = int(row["authority_epoch"]) - if ( - current_gateway == observed_gateway_id - and current_epoch == observed_epoch - ): - return { - "room_id": room_id, - "authority_gateway_id": current_gateway, - "authority_epoch": current_epoch, - "idempotent": True, - } - if observed_epoch <= current_epoch: - raise ReplicaEpochRegressionError( - "observed epoch does not supersede the stored authority" - ) - if current_gateway != local_gateway: - raise ReplicaError( - "room is not locally authoritative; nothing to demote" - ) - seq = int(row["next_seq"]) - lost_actor_json = _canonical_json( - {"kind": "system", "id": "authority-control"}, - label="actor", - max_bytes=4 * 1024, - ) - lost_payload_json = _canonical_json( - { - "previous_gateway_id": current_gateway, - "authority_gateway_id": observed_gateway_id, - "authority_epoch": observed_epoch, - }, - label="payload", - max_bytes=MAX_EVENT_JSON_BYTES, - ) - conn.execute( - """INSERT INTO hosted_room_events - (room_id, seq, event_id, kind, actor_json, authority_epoch, - payload_json, created_at) - VALUES (?, ?, ?, 'authority.lost', ?, ?, ?, ?)""", - ( - room_id, - seq, - f"system:authority-lost:{observed_epoch}", - lost_actor_json, - observed_epoch, - lost_payload_json, - now, - ), - ) - conn.execute( - """UPDATE hosted_rooms - SET authority_gateway_id=?, authority_epoch=?, - next_seq=next_seq+1, revision=revision+1, updated_at=? - WHERE room_id=?""", - (observed_gateway_id, observed_epoch, now, room_id), - ) - return { - "room_id": room_id, - "authority_gateway_id": observed_gateway_id, - "authority_epoch": observed_epoch, - "idempotent": False, + "disbanded_at": ( + float(row["disbanded_at"]) if row["disbanded_at"] is not None else None + ), + "safety_status": ( + "quarantined" if row["quarantine_reason"] is not None else "passive" + ), + "safety_reason": row["quarantine_reason"], } diff --git a/gateway/hosted_room_storage.py b/gateway/hosted_room_storage.py new file mode 100644 index 0000000000000..280f6b6bcd912 --- /dev/null +++ b/gateway/hosted_room_storage.py @@ -0,0 +1,1461 @@ +"""SQLite storage helpers for gateway-hosted Group Chats. + +This module owns schema initialization, root-database transactions, capacity, +quarantine, peer-link receipts, and row serialization. Public room operations +remain in ``gateway.hosted_rooms``. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator, Mapping, NoReturn + +from gateway.hosted_room_contract import ( + AuthorityConflictError, + DISBANDED_REPLICA_RETENTION_SECONDS, + DISBANDED_ROOM_RETENTION_SECONDS, + HostedRoomError, + MAX_ACTIVE_ROOMS, + MAX_ACTOR_ID_CHARS, + MAX_DISBANDED_ROOM_TOMBSTONES, + MAX_EVENTS_PER_ROOM, + MAX_GATEWAY_EVENT_BYTES, + MAX_ROOM_EVENT_BYTES, + MAX_ROOM_ID_CHARS, + RoomHistoryExpiredError, + RoomNotFoundError, + RoomQuarantinedError, + _EVENT_SCHEMA_COLUMNS, + _JOURNAL_MODE_LOCK_RETRIES, + _LINK_SCHEMA_COLUMNS, + _PEER_RESERVATION_SCHEMA_COLUMNS, + _QUARANTINE_SCHEMA_COLUMNS, + _REMOTE_RUN_IDENTITY_COLUMNS, + _REMOTE_RUN_SCHEMA_COLUMNS, + _REPLICA_RESERVATION_COLUMNS, + _RETIRED_ROOM_SCHEMA_COLUMNS, + _REVOKED_GRANT_ID_SCHEMA_COLUMNS, + _REVOKED_GRANT_SCHEMA_COLUMNS, + _ROOM_RESERVATION_SCHEMA_COLUMNS, + _ROOM_SAFETY_TRIGGERS, + _ROOM_SCHEMA_COLUMNS, + _canonical_json, + _validate_identifier, +) + + +def _public_api(): + """Resolve re-exported limits late so tests and callers can override them.""" + from gateway import hosted_rooms + + return hosted_rooms + + +def _primary_key_columns(conn: sqlite3.Connection, table: str) -> tuple[str, ...]: + return tuple( + str(row[1]) + for row in sorted( + (row for row in conn.execute(f"PRAGMA table_info({table})") if row[5]), + key=lambda row: int(row[5]), + ) + ) + + +def _migrate_remote_run_schema(conn: sqlite3.Connection) -> None: + """Fence legacy receipts behind a complete authority-lineage key.""" + + columns = { + str(row[1]) + for row in conn.execute("PRAGMA table_info(hosted_room_remote_runs)") + } + if ( + _REMOTE_RUN_SCHEMA_COLUMNS.issubset(columns) + and _primary_key_columns(conn, "hosted_room_remote_runs") + == _REMOTE_RUN_IDENTITY_COLUMNS + ): + return + + conn.execute("DROP TABLE IF EXISTS hosted_room_remote_runs_migrating") + conn.execute( + """CREATE TABLE hosted_room_remote_runs_migrating ( + room_id TEXT NOT NULL, + home_install_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + member_id TEXT NOT NULL, + task_id TEXT NOT NULL, + execution_generation INTEGER NOT NULL CHECK (execution_generation >= 1), + target_install_id TEXT NOT NULL, + target_profile TEXT NOT NULL, + run_id TEXT NOT NULL, + session_id TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY ( + room_id, home_install_id, authority_gateway_id, authority_epoch, + member_id, target_install_id, target_profile, task_id, + execution_generation + ) + )""" + ) + if columns: + home = "home_install_id" if "home_install_id" in columns else "'legacy'" + gateway = ( + "authority_gateway_id" + if "authority_gateway_id" in columns + else "'legacy'" + ) + epoch = "authority_epoch" if "authority_epoch" in columns else "1" + conn.execute( + f"""INSERT OR IGNORE INTO hosted_room_remote_runs_migrating( + room_id, home_install_id, authority_gateway_id, + authority_epoch, member_id, task_id, + execution_generation, target_install_id, target_profile, + run_id, session_id, created_at, updated_at + ) + SELECT room_id, {home}, {gateway}, {epoch}, member_id, task_id, + execution_generation, target_install_id, target_profile, + run_id, session_id, created_at, updated_at + FROM hosted_room_remote_runs""" + ) + conn.execute("DROP TABLE hosted_room_remote_runs") + conn.execute( + "ALTER TABLE hosted_room_remote_runs_migrating " + "RENAME TO hosted_room_remote_runs" + ) + + +def _initialize_schema(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_rooms ( + room_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + members_json TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL DEFAULT 1 CHECK (authority_epoch >= 1), + next_seq INTEGER NOT NULL DEFAULT 1 CHECK (next_seq >= 1), + event_bytes INTEGER NOT NULL DEFAULT 0 CHECK (event_bytes >= 0), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + disbanded_at REAL + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_events ( + room_id TEXT NOT NULL, + seq INTEGER NOT NULL CHECK (seq >= 1), + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + actor_json TEXT NOT NULL, + authority_epoch INTEGER CHECK (authority_epoch IS NULL OR authority_epoch >= 1), + payload_json TEXT NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (room_id, seq), + UNIQUE (room_id, event_id), + FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_retired_ids ( + room_id TEXT PRIMARY KEY, + retired_at REAL NOT NULL + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_links ( + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + target_url TEXT NOT NULL, + target_profile TEXT NOT NULL, + grant TEXT NOT NULL, + catalog_json TEXT NOT NULL, + cancellation_scope_id TEXT NOT NULL, + trace_id TEXT NOT NULL, + transport_security TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ready', + updated_at REAL NOT NULL, + PRIMARY KEY (room_id, member_id) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_remote_runs ( + room_id TEXT NOT NULL, + home_install_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + member_id TEXT NOT NULL, + task_id TEXT NOT NULL, + execution_generation INTEGER NOT NULL CHECK (execution_generation >= 1), + target_install_id TEXT NOT NULL, + target_profile TEXT NOT NULL, + run_id TEXT NOT NULL, + session_id TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY ( + room_id, home_install_id, authority_gateway_id, authority_epoch, + member_id, target_install_id, target_profile, task_id, + execution_generation + ) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_revoked_grants ( + scope_key TEXT PRIMARY KEY, + expires_at REAL NOT NULL, + revoked_before REAL NOT NULL + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_revoked_grant_ids ( + scope_key TEXT NOT NULL, + grant_id TEXT NOT NULL, + expires_at REAL NOT NULL, + PRIMARY KEY (scope_key, grant_id) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_peer_reservations ( + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + target_profile TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + expires_at REAL NOT NULL, + revoked_at REAL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (room_id, member_id, target_profile) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_quarantine ( + room_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + detected_at REAL NOT NULL + )""" + ) + # The room-ID ledger and replica identity table live in the same root DB as + # authoritative rooms. Creating the replica table here lets SQLite enforce + # namespace safety even when an older gateway process shares this database. + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_replicas ( + room_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + members_json TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + last_seq INTEGER NOT NULL DEFAULT 0 CHECK (last_seq >= 0), + latest_seq INTEGER NOT NULL DEFAULT 0, + event_bytes INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + disbanded_at REAL, + quarantined_at REAL, + quarantine_reason TEXT + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_id_reservations ( + room_id TEXT PRIMARY KEY, + owner_kind TEXT NOT NULL CHECK (owner_kind IN ('authority', 'replica')), + reserved_at REAL NOT NULL + )""" + ) + room_columns = {row[1] for row in conn.execute("PRAGMA table_info(hosted_rooms)")} + if "authority_gateway_id" not in room_columns: + conn.execute( + "ALTER TABLE hosted_rooms " + "ADD COLUMN authority_gateway_id TEXT NOT NULL DEFAULT 'legacy'" + ) + if "authority_epoch" not in room_columns: + conn.execute( + "ALTER TABLE hosted_rooms " + "ADD COLUMN authority_epoch INTEGER NOT NULL DEFAULT 1" + ) + backfill_event_bytes = "event_bytes" not in room_columns + if backfill_event_bytes: + conn.execute( + "ALTER TABLE hosted_rooms ADD COLUMN event_bytes INTEGER NOT NULL DEFAULT 0" + ) + + event_columns = { + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_events)") + } + if "actor_json" not in event_columns: + # Draft builds before the actor contract carried no identity. Preserve + # their inert replay rows explicitly as legacy system events rather + # than guessing a user or Bot author. + legacy_actor = _canonical_json( + {"kind": "system", "id": "legacy"}, + label="actor", + max_bytes=4 * 1024, + ) + escaped_actor = legacy_actor.replace("'", "''") + conn.execute( + "ALTER TABLE hosted_room_events " + f"ADD COLUMN actor_json TEXT NOT NULL DEFAULT '{escaped_actor}'" + ) + if "authority_epoch" not in event_columns: + conn.execute( + "ALTER TABLE hosted_room_events ADD COLUMN authority_epoch INTEGER" + ) + if backfill_event_bytes: + conn.execute( + """UPDATE hosted_rooms + SET event_bytes=COALESCE(( + SELECT SUM( + length(CAST(event_id AS BLOB)) + + length(CAST(kind AS BLOB)) + + length(CAST(actor_json AS BLOB)) + + length(CAST(payload_json AS BLOB)) + ) + FROM hosted_room_events + WHERE hosted_room_events.room_id=hosted_rooms.room_id + ), 0)""" + ) + # Old schemas kept the final identity tombstone in hosted_rooms itself. + # Copy those identities before bounded history pruning can remove their + # heavier room/event payloads. This compact registry is intentionally + # permanent: a stale coordinate must never name a different Group Chat. + conn.execute( + """INSERT OR IGNORE INTO hosted_room_retired_ids (room_id, retired_at) + SELECT room_id, disbanded_at FROM hosted_rooms + WHERE disbanded_at IS NOT NULL""" + ) + _migrate_remote_run_schema(conn) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_hosted_room_events_cursor + ON hosted_room_events(room_id, seq)""" + ) + # #99047 briefly exposed local-only takeover primitives that could promote + # partial replicas or let multiple gateways claim the same next epoch. + # Preserve those logs for inspection, but never let an identifiable unsafe + # lineage keep mutating after this migration. + conn.execute( + """INSERT OR IGNORE INTO hosted_room_quarantine + (room_id, reason, detected_at) + SELECT room_id, 'unsafe_replica_promotion', MIN(created_at) + FROM hosted_room_events + WHERE kind='authority.claimed' + AND payload_json LIKE '%"promoted_from_replica":true%' + GROUP BY room_id""" + ) + conn.execute( + """INSERT OR IGNORE INTO hosted_room_quarantine + (room_id, reason, detected_at) + SELECT room_id, 'unsafe_authority_demotion', MIN(created_at) + FROM hosted_room_events + WHERE kind='authority.lost' + GROUP BY room_id""" + ) + conn.execute( + """INSERT OR IGNORE INTO hosted_room_quarantine + (room_id, reason, detected_at) + SELECT rooms.room_id, 'room_namespace_collision', rooms.updated_at + FROM hosted_rooms AS rooms + JOIN hosted_room_replicas AS replicas + ON replicas.room_id=rooms.room_id""" + ) + conn.execute( + """INSERT OR IGNORE INTO hosted_room_id_reservations + (room_id, owner_kind, reserved_at) + SELECT room_id, 'authority', created_at FROM hosted_rooms""" + ) + conn.execute( + """INSERT OR IGNORE INTO hosted_room_id_reservations + (room_id, owner_kind, reserved_at) + SELECT room_id, 'replica', created_at FROM hosted_room_replicas""" + ) + for trigger in ( + """CREATE TRIGGER IF NOT EXISTS trg_hosted_rooms_reject_reserved_insert + BEFORE INSERT ON hosted_rooms + WHEN EXISTS ( + SELECT 1 FROM hosted_room_id_reservations WHERE room_id=NEW.room_id + ) + BEGIN + SELECT RAISE(ABORT, 'room_id is already reserved'); + END""", + """CREATE TRIGGER IF NOT EXISTS trg_hosted_rooms_reserve_insert + AFTER INSERT ON hosted_rooms + BEGIN + INSERT INTO hosted_room_id_reservations + (room_id, owner_kind, reserved_at) + VALUES (NEW.room_id, 'authority', NEW.created_at); + END""", + """CREATE TRIGGER IF NOT EXISTS trg_hosted_replicas_reject_reserved_insert + BEFORE INSERT ON hosted_room_replicas + WHEN EXISTS ( + SELECT 1 FROM hosted_room_id_reservations WHERE room_id=NEW.room_id + ) + BEGIN + SELECT RAISE(ABORT, 'room_id is already reserved'); + END""", + """CREATE TRIGGER IF NOT EXISTS trg_hosted_replicas_reserve_insert + AFTER INSERT ON hosted_room_replicas + BEGIN + INSERT INTO hosted_room_id_reservations + (room_id, owner_kind, reserved_at) + VALUES (NEW.room_id, 'replica', NEW.created_at); + END""", + """CREATE TRIGGER IF NOT EXISTS trg_hosted_events_reject_quarantined_insert + BEFORE INSERT ON hosted_room_events + WHEN EXISTS ( + SELECT 1 FROM hosted_room_quarantine WHERE room_id=NEW.room_id + ) + BEGIN + SELECT RAISE(ABORT, 'room authority is quarantined'); + END""", + """CREATE TRIGGER IF NOT EXISTS trg_hosted_events_quarantine_unsafe_lineage + AFTER INSERT ON hosted_room_events + WHEN NEW.kind='authority.lost' + OR ( + NEW.kind='authority.claimed' + AND NEW.payload_json LIKE '%"promoted_from_replica":true%' + ) + BEGIN + INSERT OR IGNORE INTO hosted_room_quarantine + (room_id, reason, detected_at) + VALUES ( + NEW.room_id, + CASE + WHEN NEW.kind='authority.lost' + THEN 'unsafe_authority_demotion' + ELSE 'unsafe_replica_promotion' + END, + NEW.created_at + ); + END""", + ): + conn.execute(trigger) + if not _schema_is_current(conn): + raise HostedRoomError("hosted room schema migration did not complete") + + +def _schema_is_current(conn: sqlite3.Connection) -> bool: + room_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_rooms)") + ) + event_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_events)") + ) + retired_room_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_retired_ids)") + ) + link_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_links)") + ) + remote_run_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_remote_runs)") + ) + revoked_grant_columns = frozenset( + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_revoked_grants)") + ) + revoked_grant_id_columns = frozenset( + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_revoked_grant_ids)") + ) + peer_reservation_columns = frozenset( + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_peer_reservations)") + ) + quarantine_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_quarantine)") + ) + reservation_columns = frozenset( + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_id_reservations)") + ) + replica_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_replicas)") + ) + if not _ROOM_SCHEMA_COLUMNS.issubset(room_columns): + return False + if not _EVENT_SCHEMA_COLUMNS.issubset(event_columns): + return False + if not _RETIRED_ROOM_SCHEMA_COLUMNS.issubset(retired_room_columns): + return False + if not _LINK_SCHEMA_COLUMNS.issubset(link_columns): + return False + if not _REMOTE_RUN_SCHEMA_COLUMNS.issubset(remote_run_columns): + return False + if ( + _primary_key_columns(conn, "hosted_room_remote_runs") + != _REMOTE_RUN_IDENTITY_COLUMNS + ): + return False + if not _REVOKED_GRANT_SCHEMA_COLUMNS.issubset(revoked_grant_columns): + return False + if not _REVOKED_GRANT_ID_SCHEMA_COLUMNS.issubset(revoked_grant_id_columns): + return False + if _primary_key_columns(conn, "hosted_room_revoked_grant_ids") != ( + "scope_key", + "grant_id", + ): + return False + if not _PEER_RESERVATION_SCHEMA_COLUMNS.issubset(peer_reservation_columns): + return False + if not _QUARANTINE_SCHEMA_COLUMNS.issubset(quarantine_columns): + return False + if not _ROOM_RESERVATION_SCHEMA_COLUMNS.issubset(reservation_columns): + return False + if not _REPLICA_RESERVATION_COLUMNS.issubset(replica_columns): + return False + index = conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='index' AND name='idx_hosted_room_events_cursor'""" + ).fetchone() + triggers = frozenset( + str(row[0]) + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='trigger'" + ).fetchall() + ) + return index is not None and _ROOM_SAFETY_TRIGGERS.issubset(triggers) + + +def list_room_link_records(db_path: Path | str) -> list[dict[str, Any]]: + """Return private RoomLink records without logging or formatting grants.""" + with _transaction(db_path) as conn: + rows = conn.execute( + """SELECT room_id, member_id, target_url, target_profile, grant, + catalog_json, cancellation_scope_id, trace_id, + transport_security, status, updated_at + FROM hosted_room_links + ORDER BY room_id, member_id""" + ).fetchall() + return [dict(row) for row in rows] + + +def upsert_room_link_record( + db_path: Path | str, + *, + record: Mapping[str, Any], + max_links: int, +) -> None: + """Atomically insert or replace one private RoomLink record.""" + with _transaction(db_path, immediate=True) as conn: + existing = conn.execute( + "SELECT 1 FROM hosted_room_links WHERE room_id=? AND member_id=?", + (record["room_id"], record["member_id"]), + ).fetchone() + if existing is None: + count = int( + conn.execute("SELECT COUNT(*) FROM hosted_room_links").fetchone()[0] + ) + if count >= max_links: + raise HostedRoomError("too many stored room links") + conn.execute( + """INSERT INTO hosted_room_links( + room_id, member_id, target_url, target_profile, grant, + catalog_json, cancellation_scope_id, trace_id, + transport_security, status, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(room_id, member_id) DO UPDATE SET + target_url=excluded.target_url, + target_profile=excluded.target_profile, + grant=excluded.grant, + catalog_json=excluded.catalog_json, + cancellation_scope_id=excluded.cancellation_scope_id, + trace_id=excluded.trace_id, + transport_security=excluded.transport_security, + status=excluded.status, + updated_at=excluded.updated_at""", + ( + record["room_id"], + record["member_id"], + record["target_url"], + record["target_profile"], + record["grant"], + record["catalog_json"], + record["cancellation_scope_id"], + record["trace_id"], + record["transport_security"], + record["status"], + record["updated_at"], + ), + ) + + +def update_room_link_status( + db_path: Path | str, + *, + room_id: str, + member_id: str, + status: str, + now: float | None = None, +) -> bool: + """Persist a non-secret route health classification.""" + with _transaction(db_path, immediate=True) as conn: + cursor = conn.execute( + """UPDATE hosted_room_links SET status=?, updated_at=? + WHERE room_id=? AND member_id=?""", + ( + status, + float(now if now is not None else time.time()), + room_id, + member_id, + ), + ) + return cursor.rowcount == 1 + + +def delete_room_link_records(db_path: Path | str, *, room_id: str) -> int: + """Delete persisted peer routes after their target grants are revoked.""" + with _transaction(db_path, immediate=True) as conn: + cursor = conn.execute( + "DELETE FROM hosted_room_links WHERE room_id=?", + (room_id,), + ) + return cursor.rowcount + + +def _room_grant_scope_key(claims: Mapping[str, Any]) -> str: + """Return a stable non-secret key for one room/home/target/profile scope.""" + import hashlib + + fields = { + key: str(claims.get(key) or "") + for key in ( + "room_id", + "home_install_id", + "authority_gateway_id", + "authority_epoch", + "member_id", + "target_install_id", + "target_profile", + ) + } + if not all(fields.values()): + raise HostedRoomError("room grant scope is incomplete") + return hashlib.sha256( + json.dumps(fields, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def revoke_room_grant_id( + db_path: Path | str, + *, + claims: Mapping[str, Any], + expires_at: float, + now: float | None = None, +) -> None: + """Revoke only one bearer grant without fencing concurrent replacements.""" + + timestamp = float(now if now is not None else time.time()) + expiry = float(expires_at) + if expiry <= timestamp: + return + grant_id = _validate_identifier( + claims.get("grant_id"), label="grant_id", max_chars=256 + ) + scope_key = _room_grant_scope_key(claims) + with _transaction(db_path, immediate=True) as conn: + conn.execute( + "DELETE FROM hosted_room_revoked_grant_ids WHERE expires_at<=?", + (timestamp,), + ) + conn.execute( + """INSERT INTO hosted_room_revoked_grant_ids( + scope_key, grant_id, expires_at + ) VALUES (?, ?, ?) + ON CONFLICT(scope_key, grant_id) DO UPDATE SET + expires_at=MAX(hosted_room_revoked_grant_ids.expires_at, + excluded.expires_at)""", + (scope_key, grant_id, expiry), + ) + + +def revoke_room_grant_scope( + db_path: Path | str, + *, + claims: Mapping[str, Any], + expires_at: float, + now: float | None = None, +) -> None: + """Revoke every grant issued at or before now for one exact room scope.""" + scope_key = _room_grant_scope_key(claims) + timestamp = float(now if now is not None else time.time()) + expiry = float(expires_at) + if expiry <= timestamp: + return + with _transaction(db_path, immediate=True) as conn: + conn.execute( + "DELETE FROM hosted_room_revoked_grants WHERE expires_at<=?", + (timestamp,), + ) + conn.execute( + """INSERT INTO hosted_room_revoked_grants( + scope_key, expires_at, revoked_before + ) VALUES (?, ?, ?) + ON CONFLICT(scope_key) DO UPDATE SET + expires_at=MAX(hosted_room_revoked_grants.expires_at, + excluded.expires_at), + revoked_before=MAX(hosted_room_revoked_grants.revoked_before, + excluded.revoked_before)""", + (scope_key, expiry, timestamp), + ) + conn.execute( + """UPDATE hosted_room_peer_reservations + SET revoked_at=?, updated_at=? + WHERE room_id=? AND member_id=? AND target_profile=? + AND authority_gateway_id=? AND authority_epoch=?""", + ( + timestamp, + timestamp, + str(claims.get("room_id") or ""), + str(claims.get("member_id") or ""), + str(claims.get("target_profile") or ""), + str(claims.get("authority_gateway_id") or ""), + int(claims.get("authority_epoch") or 0), + ), + ) + + +def reserve_peer_room( + db_path: Path | str, + *, + claims: Mapping[str, Any], + expires_at: float, + now: float | None = None, +) -> None: + """Fence direct Desktop prompts before the first peer run is admitted.""" + + timestamp = float(now if now is not None else time.time()) + expiry = float(expires_at) + if expiry <= timestamp: + raise HostedRoomError("peer room reservation must expire in the future") + values = ( + _validate_identifier( + claims.get("room_id"), label="room_id", max_chars=MAX_ROOM_ID_CHARS + ), + _validate_identifier( + claims.get("member_id"), label="member_id", max_chars=MAX_ACTOR_ID_CHARS + ), + _validate_identifier( + claims.get("target_profile"), + label="target_profile", + max_chars=MAX_ACTOR_ID_CHARS, + ), + _validate_identifier( + claims.get("authority_gateway_id"), + label="authority_gateway_id", + max_chars=MAX_ACTOR_ID_CHARS, + ), + int(claims.get("authority_epoch") or 0), + ) + if values[4] < 1: + raise HostedRoomError("authority_epoch must be positive") + with _transaction(db_path, immediate=True) as conn: + conn.execute( + "DELETE FROM hosted_room_peer_reservations WHERE expires_at<=?", + (timestamp,), + ) + authority_rows = conn.execute( + """SELECT authority_gateway_id, authority_epoch + FROM hosted_room_peer_reservations + WHERE room_id=? AND target_profile=? + AND expires_at>? AND revoked_at IS NULL""", + (values[0], values[2], timestamp), + ).fetchall() + if any( + int(row["authority_epoch"]) > values[4] + or ( + int(row["authority_epoch"]) == values[4] + and str(row["authority_gateway_id"]) != values[3] + ) + for row in authority_rows + ): + raise AuthorityConflictError("peer room reservation authority changed") + conn.execute( + """UPDATE hosted_room_peer_reservations + SET revoked_at=?, updated_at=? + WHERE room_id=? AND target_profile=? + AND authority_epoch values[4] + or ( + int(existing["authority_epoch"]) == values[4] + and str(existing["authority_gateway_id"]) != values[3] + ) + ): + raise AuthorityConflictError("peer room reservation authority changed") + conn.execute( + """INSERT INTO hosted_room_peer_reservations( + room_id, member_id, target_profile, authority_gateway_id, + authority_epoch, expires_at, revoked_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?) + ON CONFLICT(room_id, member_id, target_profile) DO UPDATE SET + authority_gateway_id=excluded.authority_gateway_id, + authority_epoch=excluded.authority_epoch, + expires_at=MAX(hosted_room_peer_reservations.expires_at, + excluded.expires_at), + revoked_at=NULL, + updated_at=excluded.updated_at""", + (*values, expiry, timestamp, timestamp), + ) + + +def peer_room_is_reserved( + db_path: Path | str, + *, + room_id: str, + target_profile: str, + now: float | None = None, +) -> bool: + """Return whether a live target-side RoomLink reservation fences Desktop.""" + + timestamp = float(now if now is not None else time.time()) + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT 1 FROM hosted_room_peer_reservations + WHERE room_id=? AND target_profile=? + AND expires_at>? AND revoked_at IS NULL + LIMIT 1""", + ( + _validate_identifier( + room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS + ), + _validate_identifier( + target_profile, + label="target_profile", + max_chars=MAX_ACTOR_ID_CHARS, + ), + timestamp, + ), + ).fetchone() + return row is not None + + +def peer_room_grant_is_current( + db_path: Path | str, + *, + claims: Mapping[str, Any], + now: float | None = None, +) -> bool: + """Require a grant to match the target's current live reservation.""" + + timestamp = float(now if now is not None else time.time()) + room_id = _validate_identifier( + claims.get("room_id"), label="room_id", max_chars=MAX_ROOM_ID_CHARS + ) + member_id = _validate_identifier( + claims.get("member_id"), label="member_id", max_chars=MAX_ACTOR_ID_CHARS + ) + target_profile = _validate_identifier( + claims.get("target_profile"), + label="target_profile", + max_chars=MAX_ACTOR_ID_CHARS, + ) + authority_gateway_id = _validate_identifier( + claims.get("authority_gateway_id"), + label="authority_gateway_id", + max_chars=MAX_ACTOR_ID_CHARS, + ) + authority_epoch = int(claims.get("authority_epoch") or 0) + if authority_epoch < 1: + raise HostedRoomError("authority_epoch must be positive") + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT 1 FROM hosted_room_peer_reservations + WHERE room_id=? AND member_id=? AND target_profile=? + AND authority_gateway_id=? AND authority_epoch=? + AND expires_at>? AND revoked_at IS NULL + LIMIT 1""", + ( + room_id, + member_id, + target_profile, + authority_gateway_id, + authority_epoch, + timestamp, + ), + ).fetchone() + return row is not None + + +def room_grant_is_revoked( + db_path: Path | str, + *, + claims: Mapping[str, Any], + now: float | None = None, +) -> bool: + """Return whether a grant predates its exact scope's revocation fence.""" + timestamp = float(now if now is not None else time.time()) + scope_key = _room_grant_scope_key(claims) + issued_at = float(claims.get("issued_at") or 0) + grant_id = _validate_identifier( + claims.get("grant_id"), label="grant_id", max_chars=256 + ) + scope_key = _room_grant_scope_key(claims) + with _transaction(db_path) as conn: + exact = conn.execute( + """SELECT 1 FROM hosted_room_revoked_grant_ids + WHERE scope_key=? AND grant_id=? AND expires_at>?""", + (scope_key, grant_id, timestamp), + ).fetchone() + if exact is not None: + return True + row = conn.execute( + """SELECT revoked_before FROM hosted_room_revoked_grants + WHERE scope_key=? AND expires_at>?""", + (scope_key, timestamp), + ).fetchone() + return row is not None and issued_at <= float(row["revoked_before"]) + + +def _remote_run_identity(record: Mapping[str, Any]) -> tuple[Any, ...]: + return tuple(record[column] for column in _REMOTE_RUN_IDENTITY_COLUMNS) + + +def upsert_remote_run_receipt( + db_path: Path | str, + *, + record: Mapping[str, Any], + now: float | None = None, +) -> None: + """Durably bind one logical peer task attempt to its remote run handle.""" + timestamp = float(now if now is not None else time.time()) + identity = _remote_run_identity(record) + with _transaction(db_path, immediate=True) as conn: + existing = conn.execute( + """SELECT * FROM hosted_room_remote_runs + WHERE room_id=? AND home_install_id=? + AND authority_gateway_id=? AND authority_epoch=? + AND member_id=? AND target_install_id=? + AND target_profile=? AND task_id=? + AND execution_generation=?""", + identity, + ).fetchone() + immutable = (*identity, record["run_id"], record["session_id"]) + if existing is not None: + stored = (*_remote_run_identity(existing), existing["run_id"], existing["session_id"]) + if stored != immutable: + raise HostedRoomError( + "remote run receipt conflicts with its logical task" + ) + conn.execute( + """UPDATE hosted_room_remote_runs SET updated_at=? + WHERE room_id=? AND home_install_id=? + AND authority_gateway_id=? AND authority_epoch=? + AND member_id=? AND target_install_id=? + AND target_profile=? AND task_id=? + AND execution_generation=?""", + (timestamp, *identity), + ) + return + conn.execute( + """INSERT INTO hosted_room_remote_runs( + room_id, home_install_id, authority_gateway_id, + authority_epoch, member_id, target_install_id, + target_profile, task_id, execution_generation, run_id, + session_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + *immutable, + timestamp, + timestamp, + ), + ) + + +def list_remote_run_receipts( + db_path: Path | str, + *, + room_id: str | None = None, + target_profile: str | None = None, + session_id: str | None = None, +) -> list[dict[str, Any]]: + """Return remote run handles in durable task order.""" + conditions: list[str] = [] + values: list[Any] = [] + for column, value in ( + ("room_id", room_id), + ("target_profile", target_profile), + ("session_id", session_id), + ): + if value is not None: + conditions.append(f"{column}=?") + values.append(value) + where = f" WHERE {' AND '.join(conditions)}" if conditions else "" + with _transaction(db_path) as conn: + rows = conn.execute( + "SELECT * FROM hosted_room_remote_runs" + + where + + " ORDER BY created_at, task_id, execution_generation", + values, + ).fetchall() + return [dict(row) for row in rows] + + +def remote_run_receipt( + db_path: Path | str, + *, + record: Mapping[str, Any], +) -> dict[str, Any] | None: + """Return the exact durable remote run handle for one task attempt.""" + identity = _remote_run_identity(record) + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT * FROM hosted_room_remote_runs + WHERE room_id=? AND home_install_id=? + AND authority_gateway_id=? AND authority_epoch=? + AND member_id=? AND target_install_id=? + AND target_profile=? AND task_id=? + AND execution_generation=?""", + identity, + ).fetchone() + return dict(row) if row is not None else None + + +def _connect(db_path: Path | str) -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + + path = Path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=10) + conn.row_factory = sqlite3.Row + try: + for attempt in range(_JOURNAL_MODE_LOCK_RETRIES): + try: + apply_wal_with_fallback(conn, db_label="state.db (hosted_rooms)") + break + except sqlite3.OperationalError as exc: + if ( + str(exc).lower() != "database is locked" + or attempt + 1 == _JOURNAL_MODE_LOCK_RETRIES + ): + raise + # SQLite's journal-mode pragma may not honor the connection's + # busy timeout while another first opener initializes the DB, + # especially on Windows. Retry only that transient lock class. + time.sleep(0.01 * (2**attempt)) + conn.execute("PRAGMA foreign_keys=ON") + if _schema_is_current(conn): + return conn + # Multiple profile gateways share this root database. Serialize every + # draft-schema transition in SQLite itself so a crash rolls back the + # whole DDL/data migration and another process can safely retry it. + conn.execute("BEGIN IMMEDIATE") + _public_api()._initialize_schema(conn) + conn.commit() + except Exception: + conn.rollback() + conn.close() + raise + return conn + + +def _read_connection(db_path: Path | str) -> sqlite3.Connection: + """Open the room store without steady-state journal or migration writes.""" + + path = Path(db_path) + if not path.is_file(): + initialized = _connect(path) + initialized.close() + conn = sqlite3.connect(path, timeout=10) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + if _schema_is_current(conn): + return conn + conn.close() + migrated = _connect(path) + migrated.close() + conn = sqlite3.connect(path, timeout=10) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +@contextmanager +def _transaction( + db_path: Path | str, *, immediate: bool = False +) -> Iterator[sqlite3.Connection]: + conn = _connect(db_path) + try: + if immediate: + conn.execute("BEGIN IMMEDIATE") + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _raise_room_not_found(conn: sqlite3.Connection, room_id: str) -> NoReturn: + retained = conn.execute( + "SELECT 1 FROM hosted_rooms WHERE room_id=?", + (room_id,), + ).fetchone() + if retained is not None: + # A retained disband tombstone still has replayable history. The + # caller simply did not opt into reading disbanded rooms. + raise RoomNotFoundError("hosted room not found") + retired = conn.execute( + "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", + (room_id,), + ).fetchone() + if retired is not None: + raise RoomHistoryExpiredError( + "Group Chat history expired; room_id remains permanently retired" + ) + raise RoomNotFoundError("hosted room not found") + + +def _table_exists(conn: sqlite3.Connection, table: str) -> bool: + return ( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (table,), + ).fetchone() + is not None + ) + + +def _quarantine_reason_locked(conn: sqlite3.Connection, room_id: str) -> str | None: + row = conn.execute( + "SELECT reason FROM hosted_room_quarantine WHERE room_id=?", (room_id,) + ).fetchone() + return str(row["reason"]) if row is not None else None + + +def _raise_if_quarantined(conn: sqlite3.Connection, room_id: str) -> None: + reason = _quarantine_reason_locked(conn, room_id) + if reason is not None: + raise RoomQuarantinedError( + "This Group Chat has an unverified authority takeover and is read-only " + f"until its history is reconciled ({reason})." + ) + + +def _replica_reserves_room_id_locked(conn: sqlite3.Connection, room_id: str) -> bool: + if not conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_room_replicas'""" + ).fetchone(): + return False + return ( + conn.execute( + "SELECT 1 FROM hosted_room_replicas WHERE room_id=?", (room_id,) + ).fetchone() + is not None + ) + + +def _room_id_reservation_kind_locked( + conn: sqlite3.Connection, room_id: str +) -> str | None: + row = conn.execute( + "SELECT owner_kind FROM hosted_room_id_reservations WHERE room_id=?", + (room_id,), + ).fetchone() + return str(row["owner_kind"]) if row is not None else None + + +def _room_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: + room = { + "room_id": row["room_id"], + "name": row["name"], + "members": json.loads(row["members_json"]), + "authority_gateway_id": row["authority_gateway_id"], + "authority_epoch": int(row["authority_epoch"]), + "revision": int(row["revision"]), + "created_at": float(row["created_at"]), + "updated_at": float(row["updated_at"]), + "idempotent": idempotent, + } + if "disbanded_at" in row.keys() and row["disbanded_at"] is not None: + room["disbanded_at"] = float(row["disbanded_at"]) + if "next_seq" in row.keys(): + room["latest_seq"] = int(row["next_seq"]) - 1 + if "quarantine_reason" in row.keys() and row["quarantine_reason"] is not None: + room["safety_status"] = "authority_quarantined" + room["safety_reason"] = str(row["quarantine_reason"]) + return room + + +def _event_storage_bytes( + *, event_id: str, kind: str, actor_json: str, payload_json: str +) -> int: + return len((event_id + kind + actor_json + payload_json).encode("utf-8")) + + +def _replica_event_bytes_locked(conn: sqlite3.Connection) -> int: + """Return passive-replica bytes when the optional replica table exists.""" + if not conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_room_replicas'""" + ).fetchone(): + return 0 + return int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_room_replicas" + ).fetchone()[0] + ) + + +def _assert_event_capacity( + conn: sqlite3.Connection, + *, + room: sqlite3.Row, + additional_bytes: int, + allow_control: bool = False, +) -> None: + limits = _public_api() + event_limit = limits.MAX_EVENTS_PER_ROOM + ( + limits.CONTROL_EVENT_COUNT_RESERVE if allow_control else 0 + ) + room_byte_limit = limits.MAX_ROOM_EVENT_BYTES + ( + limits.CONTROL_EVENT_BYTE_RESERVE if allow_control else 0 + ) + gateway_byte_limit = limits.MAX_GATEWAY_EVENT_BYTES + ( + limits.CONTROL_EVENT_BYTE_RESERVE if allow_control else 0 + ) + if int(room["next_seq"]) - 1 >= event_limit: + raise HostedRoomError( + "This Group Chat reached its history limit. Start a new Group Chat to continue." + ) + room_bytes = int(room["event_bytes"]) + if room_bytes + additional_bytes > room_byte_limit: + raise HostedRoomError( + "This Group Chat reached its storage limit. Start a new Group Chat to continue." + ) + replica_bytes = _replica_event_bytes_locked(conn) + gateway_bytes = replica_bytes + int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" + ).fetchone()[0] + ) + if gateway_bytes + additional_bytes > gateway_byte_limit: + _prune_disbanded_rooms_locked( + conn, + now=None, + max_gateway_event_bytes=max( + 0, gateway_byte_limit - additional_bytes - replica_bytes + ), + ) + gateway_bytes = replica_bytes + int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" + ).fetchone()[0] + ) + if gateway_bytes + additional_bytes > gateway_byte_limit: + hosted_bytes = int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" + ).fetchone()[0] + ) + _prune_disbanded_replicas_locked( + conn, + now=None, + max_replica_event_bytes=max( + 0, gateway_byte_limit - additional_bytes - hosted_bytes + ), + ) + gateway_bytes = _replica_event_bytes_locked(conn) + hosted_bytes + if gateway_bytes + additional_bytes > gateway_byte_limit: + raise HostedRoomError( + "Group Chat storage is full on this host. Delete an old Group Chat and try again." + ) + +def _prune_disbanded_rooms_locked( + conn: sqlite3.Connection, + *, + now: float | None, + max_gateway_event_bytes: int | None = None, +) -> int: + limits = _public_api() + candidates: set[str] = set() + if now is not None: + cutoff = now - limits.DISBANDED_ROOM_RETENTION_SECONDS + candidates.update( + str(row["room_id"]) + for row in conn.execute( + """SELECT room_id FROM hosted_rooms + WHERE disbanded_at IS NOT NULL AND disbanded_at<=?""", + (cutoff,), + ).fetchall() + ) + candidates.update( + str(row["room_id"]) + for row in conn.execute( + """SELECT room_id FROM hosted_rooms + WHERE disbanded_at IS NOT NULL + ORDER BY disbanded_at DESC, room_id ASC + LIMIT -1 OFFSET ?""", + (limits.MAX_DISBANDED_ROOM_TOMBSTONES,), + ).fetchall() + ) + if max_gateway_event_bytes is not None: + retained_bytes = int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" + ).fetchone()[0] + ) + if retained_bytes > max_gateway_event_bytes: + for row in conn.execute( + """SELECT room_id, event_bytes FROM hosted_rooms + WHERE disbanded_at IS NOT NULL + ORDER BY disbanded_at ASC, room_id ASC""" + ).fetchall(): + room_id = str(row["room_id"]) + if room_id not in candidates: + candidates.add(room_id) + retained_bytes -= int(row["event_bytes"]) + if retained_bytes <= max_gateway_event_bytes: + break + if not candidates: + return 0 + + placeholders = ",".join("?" for _ in candidates) + room_ids = tuple(sorted(candidates)) + conn.execute( + f"""INSERT OR IGNORE INTO hosted_room_retired_ids (room_id, retired_at) + SELECT room_id, disbanded_at FROM hosted_rooms + WHERE room_id IN ({placeholders}) AND disbanded_at IS NOT NULL""", + room_ids, + ) + dependent_tables = ( + "hosted_room_policy_transcript_state", + "hosted_room_policy_transcript", + "hosted_room_policy_publications", + "hosted_room_policy_watermarks", + "hosted_room_policy_events", + "hosted_room_policy_threads", + "hosted_room_policy_cursors", + "hosted_room_driver_tasks", + "hosted_room_driver_leases", + "hosted_room_remote_runs", + "hosted_room_links", + "hosted_room_control_commands", + "hosted_room_control_tokens", + "hosted_room_peer_controls", + "hosted_room_peer_reservations", + "hosted_room_events", + ) + for table in dependent_tables: + if _table_exists(conn, table): + conn.execute( + f"DELETE FROM {table} WHERE room_id IN ({placeholders})", + room_ids, + ) + conn.execute( + f"DELETE FROM hosted_rooms WHERE room_id IN ({placeholders})", + room_ids, + ) + return len(room_ids) + + +def _prune_disbanded_replicas_locked( + conn: sqlite3.Connection, + *, + now: float | None, + max_replica_event_bytes: int | None = None, + max_replica_rooms: int | None = None, +) -> int: + """Reclaim terminal replica payload while its room-ID reservation remains.""" + limits = _public_api() + candidates: set[str] = set() + if now is not None: + cutoff = now - limits.DISBANDED_REPLICA_RETENTION_SECONDS + candidates.update( + str(row["room_id"]) + for row in conn.execute( + """SELECT room_id FROM hosted_room_replicas + WHERE disbanded_at IS NOT NULL AND disbanded_at<=? + AND last_seq=latest_seq AND quarantine_reason IS NULL""", + (cutoff,), + ).fetchall() + ) + if max_replica_event_bytes is not None: + retained_bytes = int( + conn.execute( + "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_room_replicas" + ).fetchone()[0] + ) + if retained_bytes > max_replica_event_bytes: + for row in conn.execute( + """SELECT room_id, event_bytes FROM hosted_room_replicas + WHERE disbanded_at IS NOT NULL AND last_seq=latest_seq + AND quarantine_reason IS NULL + ORDER BY disbanded_at ASC, room_id ASC""" + ).fetchall(): + candidates.add(str(row["room_id"])) + retained_bytes -= int(row["event_bytes"]) + if retained_bytes <= max_replica_event_bytes: + break + if max_replica_rooms is not None: + retained_rooms = int( + conn.execute("SELECT COUNT(*) FROM hosted_room_replicas").fetchone()[0] + ) + if retained_rooms > max_replica_rooms: + for row in conn.execute( + """SELECT room_id FROM hosted_room_replicas + WHERE disbanded_at IS NOT NULL AND last_seq=latest_seq + AND quarantine_reason IS NULL + ORDER BY disbanded_at ASC, room_id ASC""" + ).fetchall(): + candidates.add(str(row["room_id"])) + retained_rooms -= 1 + if retained_rooms <= max_replica_rooms: + break + if not candidates: + return 0 + placeholders = ",".join("?" for _ in candidates) + room_ids = tuple(sorted(candidates)) + conn.execute( + f"DELETE FROM hosted_room_replica_events WHERE room_id IN ({placeholders})", + room_ids, + ) + deleted = conn.execute( + f"""DELETE FROM hosted_room_replicas + WHERE room_id IN ({placeholders}) AND disbanded_at IS NOT NULL + AND last_seq=latest_seq AND quarantine_reason IS NULL""", + room_ids, + ) + return max(0, int(deleted.rowcount)) + + +def prune_disbanded_rooms( + db_path: Path | str, + *, + now: float | None = None, +) -> int: + """Purge deleted Group Chat payloads while reserving their identities.""" + + timestamp = time.time() if now is None else float(now) + with _transaction(db_path, immediate=True) as conn: + return _prune_disbanded_rooms_locked(conn, now=timestamp) + + +def _event_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: + return { + "room_id": row["room_id"], + "seq": int(row["seq"]), + "event_id": row["event_id"], + "kind": row["kind"], + "actor": json.loads(row["actor_json"]), + "authority_epoch": ( + int(row["authority_epoch"]) if row["authority_epoch"] is not None else None + ), + "payload": json.loads(row["payload_json"]), + "created_at": float(row["created_at"]), + "idempotent": idempotent, + } diff --git a/gateway/hosted_rooms.py b/gateway/hosted_rooms.py index 0272495295b7e..b74acfea4387a 100644 --- a/gateway/hosted_rooms.py +++ b/gateway/hosted_rooms.py @@ -1,1414 +1,94 @@ -"""Durable state for gateway-hosted Bot Mode rooms. +"""Durable operations for gateway-hosted Bot Mode rooms. -This module owns only hosted-room identity and its append-only event log. It -does not deliver events, lease relay work, or run agent turns; those concerns -belong to the relay and the future hosted-room driver. Keeping that boundary -explicit lets the room log compose with a durable relay without creating a -second transport queue. - -The caller supplies the database path so tests and alternate gateway layouts -can isolate state. Production handlers use the gateway's root ``state.db``. +The public API in this module owns room identity and its append-only event log. +Validation lives in ``hosted_room_contract`` and root-DB mechanics live in +``hosted_room_storage``. """ from __future__ import annotations import hashlib import json -import re import sqlite3 import time -from contextlib import contextmanager from pathlib import Path -from typing import Any, Iterator, Mapping, NoReturn - - -PROTOCOL_VERSION = 2 -MAX_ROOM_ID_CHARS = 128 -MAX_EVENT_ID_CHARS = 128 -MAX_ROOM_NAME_CHARS = 200 -MAX_EVENT_KIND_CHARS = 64 -MAX_ACTOR_ID_CHARS = 128 -MAX_ACTOR_LABEL_CHARS = 200 -MAX_MEMBERS = 128 -MAX_MEMBERS_JSON_BYTES = 128 * 1024 -MAX_EVENT_JSON_BYTES = 256 * 1024 -MAX_LOG_LIMIT = 500 -MAX_LOG_PAGE_BYTES = 2 * 1024 * 1024 -MAX_ROOM_LIST_LIMIT = 500 -MAX_ACTIVE_ROOMS = 256 -MAX_DISBANDED_ROOM_TOMBSTONES = 512 -DISBANDED_ROOM_RETENTION_SECONDS = 90 * 24 * 60 * 60 -MAX_EVENTS_PER_ROOM = 50_000 -MAX_ROOM_EVENT_BYTES = 256 * 1024 * 1024 -# Leave substantial headroom below the pre-update state.db snapshot ceiling. -# Event accounting does not include SQLite indexes or repeated room ids, so the -# logical budget must stay well below the physical-file limit. -MAX_GATEWAY_EVENT_BYTES = 16 * 1024 * 1024 -CONTROL_EVENT_COUNT_RESERVE = 64 -CONTROL_EVENT_BYTE_RESERVE = 1024 * 1024 -_JOURNAL_MODE_LOCK_RETRIES = 8 - -_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") -_EVENT_KIND_RE = re.compile(r"^[a-z][a-z0-9_.-]*$") -_ROOM_SCHEMA_COLUMNS = frozenset({ - "room_id", - "name", - "members_json", - "authority_gateway_id", - "authority_epoch", - "next_seq", - "event_bytes", - "revision", - "created_at", - "updated_at", - "disbanded_at", -}) -_EVENT_SCHEMA_COLUMNS = frozenset({ - "room_id", - "seq", - "event_id", - "kind", - "actor_json", - "authority_epoch", - "payload_json", - "created_at", -}) -_RETIRED_ROOM_SCHEMA_COLUMNS = frozenset({"room_id", "retired_at"}) -_LINK_SCHEMA_COLUMNS = frozenset({ - "room_id", - "member_id", - "target_url", - "target_profile", - "grant", - "catalog_json", - "cancellation_scope_id", - "trace_id", - "transport_security", - "status", - "updated_at", -}) -_REMOTE_RUN_SCHEMA_COLUMNS = frozenset({ - "room_id", - "home_install_id", - "authority_gateway_id", - "authority_epoch", - "member_id", - "task_id", - "execution_generation", - "target_install_id", - "target_profile", - "run_id", - "session_id", - "created_at", - "updated_at", -}) -_REMOTE_RUN_IDENTITY_COLUMNS = ( - "room_id", - "home_install_id", - "authority_gateway_id", - "authority_epoch", - "member_id", - "target_install_id", - "target_profile", - "task_id", - "execution_generation", +from typing import Any + +from gateway.hosted_room_contract import ( + AuthorityConflictError, + AuthoritySupersededError, + CONTROL_EVENT_BYTE_RESERVE, + CONTROL_EVENT_COUNT_RESERVE, + DISBANDED_REPLICA_RETENTION_SECONDS, + DISBANDED_ROOM_RETENTION_SECONDS, + EventConflictError, + HostedRoomError, + MAX_ACTOR_ID_CHARS, + MAX_ACTOR_LABEL_CHARS, + MAX_ACTIVE_ROOMS, + MAX_DISBANDED_ROOM_TOMBSTONES, + MAX_EVENT_ID_CHARS, + MAX_EVENT_KIND_CHARS, + MAX_EVENT_JSON_BYTES, + MAX_EVENTS_PER_ROOM, + MAX_GATEWAY_EVENT_BYTES, + MAX_LOG_LIMIT, + MAX_LOG_PAGE_BYTES, + MAX_MEMBERS, + MAX_MEMBERS_JSON_BYTES, + MAX_ROOM_EVENT_BYTES, + MAX_ROOM_ID_CHARS, + MAX_ROOM_LIST_LIMIT, + MAX_ROOM_NAME_CHARS, + PROTOCOL_VERSION, + RoomConflictError, + RoomHistoryExpiredError, + RoomNotFoundError, + RoomProbeUnavailableError, + RoomQuarantinedError, + _canonical_json, + _legacy_members_match, + _optional_actor_field, + _validate_actor, + _validate_event_kind, + _validate_identifier, + _validate_members, + _validate_room_name, + default_db_path, + local_authority_gateway_id, + user_event_id, +) +from gateway.hosted_room_storage import ( + _assert_event_capacity, + _connect, + _event_from_row, + _event_storage_bytes, + _initialize_schema, + _prune_disbanded_replicas_locked, + _prune_disbanded_rooms_locked, + _raise_if_quarantined, + _raise_room_not_found, + _read_connection, + _replica_reserves_room_id_locked, + _room_from_row, + _room_id_reservation_kind_locked, + _schema_is_current, + _transaction, + delete_room_link_records, + list_remote_run_receipts, + list_room_link_records, + peer_room_grant_is_current, + peer_room_is_reserved, + prune_disbanded_rooms, + remote_run_receipt, + reserve_peer_room, + revoke_room_grant_id, + revoke_room_grant_scope, + room_grant_is_revoked, + update_room_link_status, + upsert_remote_run_receipt, + upsert_room_link_record, ) -_REVOKED_GRANT_SCHEMA_COLUMNS = frozenset({ - "scope_key", - "expires_at", - "revoked_before", -}) -_PEER_RESERVATION_SCHEMA_COLUMNS = frozenset({ - "room_id", - "member_id", - "target_profile", - "authority_gateway_id", - "authority_epoch", - "expires_at", - "revoked_at", - "created_at", - "updated_at", -}) - -_EVENT_KINDS_BY_ACTOR = { - "user": frozenset({"message.user"}), - "member": frozenset({"message.member"}), - "gateway": frozenset({ - "member.unavailable", - "room.activity", - "room.stop_requested", - "turn.deferred", - "turn.reassigned", - "turn.cancelled", - "turn.failed", - "turn.settled", - "turn.started", - }), - "system": frozenset({ - "authority.claimed", - "authority.lost", - "room.created", - "room.disbanded", - "room.members_changed", - "room.renamed", - }), -} -_ACTOR_FIELDS = frozenset({"kind", "id", "display_name", "profile", "connection_id"}) - - -class HostedRoomError(ValueError): - """Base class for invalid or conflicting hosted-room operations.""" - - -class RoomNotFoundError(HostedRoomError): - """Raised when a room does not exist or has been disbanded.""" - - -class RoomHistoryExpiredError(RoomNotFoundError): - """Raised when a retired room remains reserved after history compaction.""" - - reason = "room_history_expired" - - -class RoomConflictError(HostedRoomError): - """Raised when an idempotency key is reused for different room state.""" - - -class RoomProbeUnavailableError(HostedRoomError): - """Raised when a non-blocking ownership probe cannot read the room store.""" - - -class EventConflictError(HostedRoomError): - """Raised when an event id is reused with different immutable content.""" - - -class AuthorityConflictError(HostedRoomError): - """Raised when a stale room authority attempts to mutate hosted state.""" - - reason = "authority_conflict" - - -class AuthoritySupersededError(AuthorityConflictError): - """Raised when a successful authority claim was later superseded.""" - - -def _primary_key_columns(conn: sqlite3.Connection, table: str) -> tuple[str, ...]: - return tuple( - str(row[1]) - for row in sorted( - (row for row in conn.execute(f"PRAGMA table_info({table})") if row[5]), - key=lambda row: int(row[5]), - ) - ) - - -def _migrate_remote_run_schema(conn: sqlite3.Connection) -> None: - """Fence legacy receipts behind a complete authority-lineage key.""" - - columns = { - str(row[1]) - for row in conn.execute("PRAGMA table_info(hosted_room_remote_runs)") - } - if ( - _REMOTE_RUN_SCHEMA_COLUMNS.issubset(columns) - and _primary_key_columns(conn, "hosted_room_remote_runs") - == _REMOTE_RUN_IDENTITY_COLUMNS - ): - return - - conn.execute("DROP TABLE IF EXISTS hosted_room_remote_runs_migrating") - conn.execute( - """CREATE TABLE hosted_room_remote_runs_migrating ( - room_id TEXT NOT NULL, - home_install_id TEXT NOT NULL, - authority_gateway_id TEXT NOT NULL, - authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), - member_id TEXT NOT NULL, - task_id TEXT NOT NULL, - execution_generation INTEGER NOT NULL CHECK (execution_generation >= 1), - target_install_id TEXT NOT NULL, - target_profile TEXT NOT NULL, - run_id TEXT NOT NULL, - session_id TEXT NOT NULL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY ( - room_id, home_install_id, authority_gateway_id, authority_epoch, - member_id, target_install_id, target_profile, task_id, - execution_generation - ) - )""" - ) - if columns: - home = "home_install_id" if "home_install_id" in columns else "'legacy'" - gateway = ( - "authority_gateway_id" - if "authority_gateway_id" in columns - else "'legacy'" - ) - epoch = "authority_epoch" if "authority_epoch" in columns else "1" - conn.execute( - f"""INSERT OR IGNORE INTO hosted_room_remote_runs_migrating( - room_id, home_install_id, authority_gateway_id, - authority_epoch, member_id, task_id, - execution_generation, target_install_id, target_profile, - run_id, session_id, created_at, updated_at - ) - SELECT room_id, {home}, {gateway}, {epoch}, member_id, task_id, - execution_generation, target_install_id, target_profile, - run_id, session_id, created_at, updated_at - FROM hosted_room_remote_runs""" - ) - conn.execute("DROP TABLE hosted_room_remote_runs") - conn.execute( - "ALTER TABLE hosted_room_remote_runs_migrating " - "RENAME TO hosted_room_remote_runs" - ) - - -def default_db_path() -> Path: - """Return the gateway-wide state database for the active install.""" - from hermes_constants import get_hermes_home - - home = get_hermes_home() - root = home.parent.parent if home.parent.name == "profiles" else home - return root / "state.db" - - -def local_authority_gateway_id() -> str: - """Return the stable server-owned identity for hosted-room authority.""" - from hermes_cli.install_identity import get_install_id - - install_id = get_install_id() - if not install_id: - raise HostedRoomError("stable gateway install identity is unavailable") - return _validate_identifier( - f"install:{install_id}", - label="authority_gateway_id", - max_chars=MAX_ACTOR_ID_CHARS, - ) - - -def _canonical_json(value: Any, *, label: str, max_bytes: int) -> str: - try: - encoded = json.dumps( - value, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ) - except (TypeError, ValueError, RecursionError) as exc: - raise HostedRoomError(f"{label} must be JSON-serializable") from exc - if len(encoded.encode("utf-8")) > max_bytes: - raise HostedRoomError(f"{label} is too large") - return encoded - - -def _validate_identifier(value: Any, *, label: str, max_chars: int) -> str: - if not isinstance(value, str): - raise HostedRoomError(f"{label} must be a string") - value = value.strip() - if not value or len(value) > max_chars or not _IDENTIFIER_RE.fullmatch(value): - raise HostedRoomError(f"invalid {label}") - return value - - -def user_event_id(client_event_id: Any) -> str: - """Map a client retry key into the server-owned user-event namespace.""" - normalized = _validate_identifier( - client_event_id, - label="event_id", - max_chars=MAX_EVENT_ID_CHARS, - ) - digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() - return f"user:{digest}" - - -def _validate_room_name(value: Any) -> str: - if not isinstance(value, str): - raise HostedRoomError("name must be a string") - value = value.strip() - if not value or len(value) > MAX_ROOM_NAME_CHARS: - raise HostedRoomError("invalid room name") - return value - - -def _validate_members(value: Any) -> tuple[list[dict[str, Any]], str]: - if not isinstance(value, list): - raise HostedRoomError("members must be a list") - if len(value) > MAX_MEMBERS: - raise HostedRoomError("too many room members") - members: list[dict[str, Any]] = [] - for member in value: - if not isinstance(member, dict): - raise HostedRoomError("each room member must be an object") - members.append(dict(member)) - encoded = _canonical_json( - members, - label="members", - max_bytes=MAX_MEMBERS_JSON_BYTES, - ) - return members, encoded - - -def _legacy_members_match( - existing_json: str, - proposed: list[dict[str, Any]], -) -> bool: - """Allow adoption to add routing metadata an older room could not store.""" - - try: - existing = json.loads(existing_json) - except (TypeError, ValueError): - return False - if not isinstance(existing, list) or len(existing) != len(proposed): - return False - for previous, current in zip(existing, proposed, strict=True): - if not isinstance(previous, dict): - return False - previous = dict(previous) - current = dict(current) - previous_target = previous.pop("target", None) - current_target = current.pop("target", None) - if previous != current: - return False - if previous_target not in (None, {}) and previous_target != current_target: - return False - return True - - -def _validate_event_kind(value: Any) -> str: - if not isinstance(value, str): - raise HostedRoomError("kind must be a string") - value = value.strip() - if ( - not value - or len(value) > MAX_EVENT_KIND_CHARS - or not _EVENT_KIND_RE.fullmatch(value) - ): - raise HostedRoomError("invalid event kind") - return value - - -def _optional_actor_field(actor: dict[str, Any], field: str, max_chars: int) -> str: - value = actor.get(field) - if value is None: - return "" - if not isinstance(value, str): - raise HostedRoomError(f"actor.{field} must be a string") - value = value.strip() - if len(value) > max_chars: - raise HostedRoomError(f"actor.{field} is too long") - return value - - -def _validate_actor(value: Any, *, kind: str) -> tuple[dict[str, str], str]: - if not isinstance(value, dict): - raise HostedRoomError("actor must be an object") - unknown = set(value) - _ACTOR_FIELDS - if unknown: - raise HostedRoomError(f"unknown actor fields: {', '.join(sorted(unknown))}") - - actor_kind = value.get("kind") - if not isinstance(actor_kind, str) or actor_kind not in _EVENT_KINDS_BY_ACTOR: - raise HostedRoomError("invalid actor.kind") - if kind not in _EVENT_KINDS_BY_ACTOR[actor_kind]: - raise HostedRoomError(f"actor kind '{actor_kind}' cannot append '{kind}'") - - actor_id = _validate_identifier( - value.get("id"), - label="actor.id", - max_chars=MAX_ACTOR_ID_CHARS, - ) - actor = {"kind": actor_kind, "id": actor_id} - for field, max_chars in ( - ("display_name", MAX_ACTOR_LABEL_CHARS), - ("profile", MAX_ACTOR_ID_CHARS), - ("connection_id", MAX_ACTOR_ID_CHARS), - ): - field_value = _optional_actor_field(value, field, max_chars) - if field_value: - actor[field] = field_value - encoded = _canonical_json( - actor, - label="actor", - max_bytes=4 * 1024, - ) - return actor, encoded - - -def _initialize_schema(conn: sqlite3.Connection) -> None: - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_rooms ( - room_id TEXT PRIMARY KEY, - name TEXT NOT NULL, - members_json TEXT NOT NULL, - authority_gateway_id TEXT NOT NULL, - authority_epoch INTEGER NOT NULL DEFAULT 1 CHECK (authority_epoch >= 1), - next_seq INTEGER NOT NULL DEFAULT 1 CHECK (next_seq >= 1), - event_bytes INTEGER NOT NULL DEFAULT 0 CHECK (event_bytes >= 0), - revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - disbanded_at REAL - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_events ( - room_id TEXT NOT NULL, - seq INTEGER NOT NULL CHECK (seq >= 1), - event_id TEXT NOT NULL, - kind TEXT NOT NULL, - actor_json TEXT NOT NULL, - authority_epoch INTEGER CHECK (authority_epoch IS NULL OR authority_epoch >= 1), - payload_json TEXT NOT NULL, - created_at REAL NOT NULL, - PRIMARY KEY (room_id, seq), - UNIQUE (room_id, event_id), - FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id) - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_retired_ids ( - room_id TEXT PRIMARY KEY, - retired_at REAL NOT NULL - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_links ( - room_id TEXT NOT NULL, - member_id TEXT NOT NULL, - target_url TEXT NOT NULL, - target_profile TEXT NOT NULL, - grant TEXT NOT NULL, - catalog_json TEXT NOT NULL, - cancellation_scope_id TEXT NOT NULL, - trace_id TEXT NOT NULL, - transport_security TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'ready', - updated_at REAL NOT NULL, - PRIMARY KEY (room_id, member_id) - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_remote_runs ( - room_id TEXT NOT NULL, - home_install_id TEXT NOT NULL, - authority_gateway_id TEXT NOT NULL, - authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), - member_id TEXT NOT NULL, - task_id TEXT NOT NULL, - execution_generation INTEGER NOT NULL CHECK (execution_generation >= 1), - target_install_id TEXT NOT NULL, - target_profile TEXT NOT NULL, - run_id TEXT NOT NULL, - session_id TEXT NOT NULL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY ( - room_id, home_install_id, authority_gateway_id, authority_epoch, - member_id, target_install_id, target_profile, task_id, - execution_generation - ) - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_revoked_grants ( - scope_key TEXT PRIMARY KEY, - expires_at REAL NOT NULL, - revoked_before REAL NOT NULL - )""" - ) - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_peer_reservations ( - room_id TEXT NOT NULL, - member_id TEXT NOT NULL, - target_profile TEXT NOT NULL, - authority_gateway_id TEXT NOT NULL, - authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), - expires_at REAL NOT NULL, - revoked_at REAL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY (room_id, member_id, target_profile) - )""" - ) - room_columns = {row[1] for row in conn.execute("PRAGMA table_info(hosted_rooms)")} - if "authority_gateway_id" not in room_columns: - conn.execute( - "ALTER TABLE hosted_rooms " - "ADD COLUMN authority_gateway_id TEXT NOT NULL DEFAULT 'legacy'" - ) - if "authority_epoch" not in room_columns: - conn.execute( - "ALTER TABLE hosted_rooms " - "ADD COLUMN authority_epoch INTEGER NOT NULL DEFAULT 1" - ) - backfill_event_bytes = "event_bytes" not in room_columns - if backfill_event_bytes: - conn.execute( - "ALTER TABLE hosted_rooms ADD COLUMN event_bytes INTEGER NOT NULL DEFAULT 0" - ) - - event_columns = { - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_events)") - } - if "actor_json" not in event_columns: - # Draft builds before the actor contract carried no identity. Preserve - # their inert replay rows explicitly as legacy system events rather - # than guessing a user or Bot author. - legacy_actor = _canonical_json( - {"kind": "system", "id": "legacy"}, - label="actor", - max_bytes=4 * 1024, - ) - escaped_actor = legacy_actor.replace("'", "''") - conn.execute( - "ALTER TABLE hosted_room_events " - f"ADD COLUMN actor_json TEXT NOT NULL DEFAULT '{escaped_actor}'" - ) - if "authority_epoch" not in event_columns: - conn.execute( - "ALTER TABLE hosted_room_events ADD COLUMN authority_epoch INTEGER" - ) - if backfill_event_bytes: - conn.execute( - """UPDATE hosted_rooms - SET event_bytes=COALESCE(( - SELECT SUM( - length(CAST(event_id AS BLOB)) + - length(CAST(kind AS BLOB)) + - length(CAST(actor_json AS BLOB)) + - length(CAST(payload_json AS BLOB)) - ) - FROM hosted_room_events - WHERE hosted_room_events.room_id=hosted_rooms.room_id - ), 0)""" - ) - # Old schemas kept the final identity tombstone in hosted_rooms itself. - # Copy those identities before bounded history pruning can remove their - # heavier room/event payloads. This compact registry is intentionally - # permanent: a stale coordinate must never name a different Group Chat. - conn.execute( - """INSERT OR IGNORE INTO hosted_room_retired_ids (room_id, retired_at) - SELECT room_id, disbanded_at FROM hosted_rooms - WHERE disbanded_at IS NOT NULL""" - ) - _migrate_remote_run_schema(conn) - conn.execute( - """CREATE INDEX IF NOT EXISTS idx_hosted_room_events_cursor - ON hosted_room_events(room_id, seq)""" - ) - if not _schema_is_current(conn): - raise HostedRoomError("hosted room schema migration did not complete") - - -def _schema_is_current(conn: sqlite3.Connection) -> bool: - room_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_rooms)") - ) - event_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_events)") - ) - retired_room_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_retired_ids)") - ) - link_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_links)") - ) - remote_run_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_remote_runs)") - ) - revoked_grant_columns = frozenset( - row[1] - for row in conn.execute("PRAGMA table_info(hosted_room_revoked_grants)") - ) - peer_reservation_columns = frozenset( - row[1] - for row in conn.execute("PRAGMA table_info(hosted_room_peer_reservations)") - ) - if not _ROOM_SCHEMA_COLUMNS.issubset(room_columns): - return False - if not _EVENT_SCHEMA_COLUMNS.issubset(event_columns): - return False - if not _RETIRED_ROOM_SCHEMA_COLUMNS.issubset(retired_room_columns): - return False - if not _LINK_SCHEMA_COLUMNS.issubset(link_columns): - return False - if not _REMOTE_RUN_SCHEMA_COLUMNS.issubset(remote_run_columns): - return False - if ( - _primary_key_columns(conn, "hosted_room_remote_runs") - != _REMOTE_RUN_IDENTITY_COLUMNS - ): - return False - if not _REVOKED_GRANT_SCHEMA_COLUMNS.issubset(revoked_grant_columns): - return False - if not _PEER_RESERVATION_SCHEMA_COLUMNS.issubset(peer_reservation_columns): - return False - index = conn.execute( - """SELECT 1 FROM sqlite_master - WHERE type='index' AND name='idx_hosted_room_events_cursor'""" - ).fetchone() - return index is not None - - -def list_room_link_records(db_path: Path | str) -> list[dict[str, Any]]: - """Return private RoomLink records without logging or formatting grants.""" - with _transaction(db_path) as conn: - rows = conn.execute( - """SELECT room_id, member_id, target_url, target_profile, grant, - catalog_json, cancellation_scope_id, trace_id, - transport_security, status, updated_at - FROM hosted_room_links - ORDER BY room_id, member_id""" - ).fetchall() - return [dict(row) for row in rows] - - -def upsert_room_link_record( - db_path: Path | str, - *, - record: Mapping[str, Any], - max_links: int, -) -> None: - """Atomically insert or replace one private RoomLink record.""" - with _transaction(db_path, immediate=True) as conn: - existing = conn.execute( - "SELECT 1 FROM hosted_room_links WHERE room_id=? AND member_id=?", - (record["room_id"], record["member_id"]), - ).fetchone() - if existing is None: - count = int( - conn.execute("SELECT COUNT(*) FROM hosted_room_links").fetchone()[0] - ) - if count >= max_links: - raise HostedRoomError("too many stored room links") - conn.execute( - """INSERT INTO hosted_room_links( - room_id, member_id, target_url, target_profile, grant, - catalog_json, cancellation_scope_id, trace_id, - transport_security, status, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(room_id, member_id) DO UPDATE SET - target_url=excluded.target_url, - target_profile=excluded.target_profile, - grant=excluded.grant, - catalog_json=excluded.catalog_json, - cancellation_scope_id=excluded.cancellation_scope_id, - trace_id=excluded.trace_id, - transport_security=excluded.transport_security, - status=excluded.status, - updated_at=excluded.updated_at""", - ( - record["room_id"], - record["member_id"], - record["target_url"], - record["target_profile"], - record["grant"], - record["catalog_json"], - record["cancellation_scope_id"], - record["trace_id"], - record["transport_security"], - record["status"], - record["updated_at"], - ), - ) - - -def update_room_link_status( - db_path: Path | str, - *, - room_id: str, - member_id: str, - status: str, - now: float | None = None, -) -> bool: - """Persist a non-secret route health classification.""" - with _transaction(db_path, immediate=True) as conn: - cursor = conn.execute( - """UPDATE hosted_room_links SET status=?, updated_at=? - WHERE room_id=? AND member_id=?""", - ( - status, - float(now if now is not None else time.time()), - room_id, - member_id, - ), - ) - return cursor.rowcount == 1 - - -def delete_room_link_records(db_path: Path | str, *, room_id: str) -> int: - """Delete persisted peer routes after their target grants are revoked.""" - with _transaction(db_path, immediate=True) as conn: - cursor = conn.execute( - "DELETE FROM hosted_room_links WHERE room_id=?", - (room_id,), - ) - return cursor.rowcount - - -def _room_grant_scope_key(claims: Mapping[str, Any]) -> str: - """Return a stable non-secret key for one room/home/target/profile scope.""" - import hashlib - - fields = { - key: str(claims.get(key) or "") - for key in ( - "room_id", - "home_install_id", - "authority_gateway_id", - "authority_epoch", - "member_id", - "target_install_id", - "target_profile", - ) - } - if not all(fields.values()): - raise HostedRoomError("room grant scope is incomplete") - return hashlib.sha256( - json.dumps(fields, sort_keys=True, separators=(",", ":")).encode("utf-8") - ).hexdigest() - - -def revoke_room_grant_scope( - db_path: Path | str, - *, - claims: Mapping[str, Any], - expires_at: float, - now: float | None = None, -) -> None: - """Revoke every grant issued at or before now for one exact room scope.""" - scope_key = _room_grant_scope_key(claims) - timestamp = float(now if now is not None else time.time()) - expiry = float(expires_at) - if expiry <= timestamp: - return - with _transaction(db_path, immediate=True) as conn: - conn.execute( - "DELETE FROM hosted_room_revoked_grants WHERE expires_at<=?", - (timestamp,), - ) - conn.execute( - """INSERT INTO hosted_room_revoked_grants( - scope_key, expires_at, revoked_before - ) VALUES (?, ?, ?) - ON CONFLICT(scope_key) DO UPDATE SET - expires_at=MAX(hosted_room_revoked_grants.expires_at, - excluded.expires_at), - revoked_before=MAX(hosted_room_revoked_grants.revoked_before, - excluded.revoked_before)""", - (scope_key, expiry, timestamp), - ) - conn.execute( - """UPDATE hosted_room_peer_reservations - SET revoked_at=?, updated_at=? - WHERE room_id=? AND member_id=? AND target_profile=? - AND authority_gateway_id=? AND authority_epoch=?""", - ( - timestamp, - timestamp, - str(claims.get("room_id") or ""), - str(claims.get("member_id") or ""), - str(claims.get("target_profile") or ""), - str(claims.get("authority_gateway_id") or ""), - int(claims.get("authority_epoch") or 0), - ), - ) - - -def reserve_peer_room( - db_path: Path | str, - *, - claims: Mapping[str, Any], - expires_at: float, - now: float | None = None, -) -> None: - """Fence direct Desktop prompts before the first peer run is admitted.""" - - timestamp = float(now if now is not None else time.time()) - expiry = float(expires_at) - if expiry <= timestamp: - raise HostedRoomError("peer room reservation must expire in the future") - values = ( - _validate_identifier( - claims.get("room_id"), label="room_id", max_chars=MAX_ROOM_ID_CHARS - ), - _validate_identifier( - claims.get("member_id"), label="member_id", max_chars=MAX_ACTOR_ID_CHARS - ), - _validate_identifier( - claims.get("target_profile"), - label="target_profile", - max_chars=MAX_ACTOR_ID_CHARS, - ), - _validate_identifier( - claims.get("authority_gateway_id"), - label="authority_gateway_id", - max_chars=MAX_ACTOR_ID_CHARS, - ), - int(claims.get("authority_epoch") or 0), - ) - if values[4] < 1: - raise HostedRoomError("authority_epoch must be positive") - with _transaction(db_path, immediate=True) as conn: - conn.execute( - "DELETE FROM hosted_room_peer_reservations WHERE expires_at<=?", - (timestamp,), - ) - authority_rows = conn.execute( - """SELECT authority_gateway_id, authority_epoch - FROM hosted_room_peer_reservations - WHERE room_id=? AND target_profile=? - AND expires_at>? AND revoked_at IS NULL""", - (values[0], values[2], timestamp), - ).fetchall() - if any( - int(row["authority_epoch"]) > values[4] - or ( - int(row["authority_epoch"]) == values[4] - and str(row["authority_gateway_id"]) != values[3] - ) - for row in authority_rows - ): - raise AuthorityConflictError("peer room reservation authority changed") - conn.execute( - """UPDATE hosted_room_peer_reservations - SET revoked_at=?, updated_at=? - WHERE room_id=? AND target_profile=? - AND authority_epoch values[4] - or ( - int(existing["authority_epoch"]) == values[4] - and str(existing["authority_gateway_id"]) != values[3] - ) - ): - raise AuthorityConflictError("peer room reservation authority changed") - conn.execute( - """INSERT INTO hosted_room_peer_reservations( - room_id, member_id, target_profile, authority_gateway_id, - authority_epoch, expires_at, revoked_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?) - ON CONFLICT(room_id, member_id, target_profile) DO UPDATE SET - authority_gateway_id=excluded.authority_gateway_id, - authority_epoch=excluded.authority_epoch, - expires_at=MAX(hosted_room_peer_reservations.expires_at, - excluded.expires_at), - revoked_at=NULL, - updated_at=excluded.updated_at""", - (*values, expiry, timestamp, timestamp), - ) - - -def peer_room_is_reserved( - db_path: Path | str, - *, - room_id: str, - target_profile: str, - now: float | None = None, -) -> bool: - """Return whether a live target-side RoomLink reservation fences Desktop.""" - - timestamp = float(now if now is not None else time.time()) - with _transaction(db_path) as conn: - row = conn.execute( - """SELECT 1 FROM hosted_room_peer_reservations - WHERE room_id=? AND target_profile=? - AND expires_at>? AND revoked_at IS NULL - LIMIT 1""", - ( - _validate_identifier( - room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS - ), - _validate_identifier( - target_profile, - label="target_profile", - max_chars=MAX_ACTOR_ID_CHARS, - ), - timestamp, - ), - ).fetchone() - return row is not None - - -def peer_room_grant_is_current( - db_path: Path | str, - *, - claims: Mapping[str, Any], - now: float | None = None, -) -> bool: - """Require a grant to match the target's current live reservation.""" - - timestamp = float(now if now is not None else time.time()) - room_id = _validate_identifier( - claims.get("room_id"), label="room_id", max_chars=MAX_ROOM_ID_CHARS - ) - member_id = _validate_identifier( - claims.get("member_id"), label="member_id", max_chars=MAX_ACTOR_ID_CHARS - ) - target_profile = _validate_identifier( - claims.get("target_profile"), - label="target_profile", - max_chars=MAX_ACTOR_ID_CHARS, - ) - authority_gateway_id = _validate_identifier( - claims.get("authority_gateway_id"), - label="authority_gateway_id", - max_chars=MAX_ACTOR_ID_CHARS, - ) - authority_epoch = int(claims.get("authority_epoch") or 0) - if authority_epoch < 1: - raise HostedRoomError("authority_epoch must be positive") - with _transaction(db_path) as conn: - row = conn.execute( - """SELECT 1 FROM hosted_room_peer_reservations - WHERE room_id=? AND member_id=? AND target_profile=? - AND authority_gateway_id=? AND authority_epoch=? - AND expires_at>? AND revoked_at IS NULL - LIMIT 1""", - ( - room_id, - member_id, - target_profile, - authority_gateway_id, - authority_epoch, - timestamp, - ), - ).fetchone() - return row is not None - - -def room_grant_is_revoked( - db_path: Path | str, - *, - claims: Mapping[str, Any], - now: float | None = None, -) -> bool: - """Return whether a grant predates its exact scope's revocation fence.""" - timestamp = float(now if now is not None else time.time()) - scope_key = _room_grant_scope_key(claims) - issued_at = float(claims.get("issued_at") or 0) - with _transaction(db_path) as conn: - row = conn.execute( - """SELECT revoked_before FROM hosted_room_revoked_grants - WHERE scope_key=? AND expires_at>?""", - (scope_key, timestamp), - ).fetchone() - return row is not None and issued_at <= float(row["revoked_before"]) - - -def _remote_run_identity(record: Mapping[str, Any]) -> tuple[Any, ...]: - return tuple(record[column] for column in _REMOTE_RUN_IDENTITY_COLUMNS) - - -def upsert_remote_run_receipt( - db_path: Path | str, - *, - record: Mapping[str, Any], - now: float | None = None, -) -> None: - """Durably bind one logical peer task attempt to its remote run handle.""" - timestamp = float(now if now is not None else time.time()) - identity = _remote_run_identity(record) - with _transaction(db_path, immediate=True) as conn: - existing = conn.execute( - """SELECT * FROM hosted_room_remote_runs - WHERE room_id=? AND home_install_id=? - AND authority_gateway_id=? AND authority_epoch=? - AND member_id=? AND target_install_id=? - AND target_profile=? AND task_id=? - AND execution_generation=?""", - identity, - ).fetchone() - immutable = (*identity, record["run_id"], record["session_id"]) - if existing is not None: - stored = (*_remote_run_identity(existing), existing["run_id"], existing["session_id"]) - if stored != immutable: - raise HostedRoomError( - "remote run receipt conflicts with its logical task" - ) - conn.execute( - """UPDATE hosted_room_remote_runs SET updated_at=? - WHERE room_id=? AND home_install_id=? - AND authority_gateway_id=? AND authority_epoch=? - AND member_id=? AND target_install_id=? - AND target_profile=? AND task_id=? - AND execution_generation=?""", - (timestamp, *identity), - ) - return - conn.execute( - """INSERT INTO hosted_room_remote_runs( - room_id, home_install_id, authority_gateway_id, - authority_epoch, member_id, target_install_id, - target_profile, task_id, execution_generation, run_id, - session_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - *immutable, - timestamp, - timestamp, - ), - ) - - -def list_remote_run_receipts( - db_path: Path | str, - *, - room_id: str | None = None, - target_profile: str | None = None, - session_id: str | None = None, -) -> list[dict[str, Any]]: - """Return remote run handles in durable task order.""" - conditions: list[str] = [] - values: list[Any] = [] - for column, value in ( - ("room_id", room_id), - ("target_profile", target_profile), - ("session_id", session_id), - ): - if value is not None: - conditions.append(f"{column}=?") - values.append(value) - where = f" WHERE {' AND '.join(conditions)}" if conditions else "" - with _transaction(db_path) as conn: - rows = conn.execute( - "SELECT * FROM hosted_room_remote_runs" - + where - + " ORDER BY created_at, task_id, execution_generation", - values, - ).fetchall() - return [dict(row) for row in rows] - - -def remote_run_receipt( - db_path: Path | str, - *, - record: Mapping[str, Any], -) -> dict[str, Any] | None: - """Return the exact durable remote run handle for one task attempt.""" - identity = _remote_run_identity(record) - with _transaction(db_path) as conn: - row = conn.execute( - """SELECT * FROM hosted_room_remote_runs - WHERE room_id=? AND home_install_id=? - AND authority_gateway_id=? AND authority_epoch=? - AND member_id=? AND target_install_id=? - AND target_profile=? AND task_id=? - AND execution_generation=?""", - identity, - ).fetchone() - return dict(row) if row is not None else None - - -def _connect(db_path: Path | str) -> sqlite3.Connection: - from hermes_state import apply_wal_with_fallback - - path = Path(db_path) - path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(path, timeout=10) - conn.row_factory = sqlite3.Row - try: - for attempt in range(_JOURNAL_MODE_LOCK_RETRIES): - try: - apply_wal_with_fallback(conn, db_label="state.db (hosted_rooms)") - break - except sqlite3.OperationalError as exc: - if ( - str(exc).lower() != "database is locked" - or attempt + 1 == _JOURNAL_MODE_LOCK_RETRIES - ): - raise - # SQLite's journal-mode pragma may not honor the connection's - # busy timeout while another first opener initializes the DB, - # especially on Windows. Retry only that transient lock class. - time.sleep(0.01 * (2**attempt)) - conn.execute("PRAGMA foreign_keys=ON") - if _schema_is_current(conn): - return conn - # Multiple profile gateways share this root database. Serialize every - # draft-schema transition in SQLite itself so a crash rolls back the - # whole DDL/data migration and another process can safely retry it. - conn.execute("BEGIN IMMEDIATE") - _initialize_schema(conn) - conn.commit() - except Exception: - conn.rollback() - conn.close() - raise - return conn - - -def _read_connection(db_path: Path | str) -> sqlite3.Connection: - """Open the room store without steady-state journal or migration writes.""" - - path = Path(db_path) - if not path.is_file(): - initialized = _connect(path) - initialized.close() - conn = sqlite3.connect(path, timeout=10) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys=ON") - if _schema_is_current(conn): - return conn - conn.close() - migrated = _connect(path) - migrated.close() - conn = sqlite3.connect(path, timeout=10) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys=ON") - return conn - - -@contextmanager -def _transaction( - db_path: Path | str, *, immediate: bool = False -) -> Iterator[sqlite3.Connection]: - conn = _connect(db_path) - try: - if immediate: - conn.execute("BEGIN IMMEDIATE") - yield conn - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() - - -def _raise_room_not_found(conn: sqlite3.Connection, room_id: str) -> NoReturn: - retained = conn.execute( - "SELECT 1 FROM hosted_rooms WHERE room_id=?", - (room_id,), - ).fetchone() - if retained is not None: - # A retained disband tombstone still has replayable history. The - # caller simply did not opt into reading disbanded rooms. - raise RoomNotFoundError("hosted room not found") - retired = conn.execute( - "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", - (room_id,), - ).fetchone() - if retired is not None: - raise RoomHistoryExpiredError( - "Group Chat history expired; room_id remains permanently retired" - ) - raise RoomNotFoundError("hosted room not found") - - -def _table_exists(conn: sqlite3.Connection, table: str) -> bool: - return ( - conn.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", - (table,), - ).fetchone() - is not None - ) - - -def _room_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: - room = { - "room_id": row["room_id"], - "name": row["name"], - "members": json.loads(row["members_json"]), - "authority_gateway_id": row["authority_gateway_id"], - "authority_epoch": int(row["authority_epoch"]), - "revision": int(row["revision"]), - "created_at": float(row["created_at"]), - "updated_at": float(row["updated_at"]), - "idempotent": idempotent, - } - if "disbanded_at" in row.keys() and row["disbanded_at"] is not None: - room["disbanded_at"] = float(row["disbanded_at"]) - if "next_seq" in row.keys(): - room["latest_seq"] = int(row["next_seq"]) - 1 - return room - - -def _event_storage_bytes( - *, event_id: str, kind: str, actor_json: str, payload_json: str -) -> int: - return len((event_id + kind + actor_json + payload_json).encode("utf-8")) - - -def _assert_event_capacity( - conn: sqlite3.Connection, - *, - room: sqlite3.Row, - additional_bytes: int, - allow_control: bool = False, -) -> None: - event_limit = MAX_EVENTS_PER_ROOM + ( - CONTROL_EVENT_COUNT_RESERVE if allow_control else 0 - ) - room_byte_limit = MAX_ROOM_EVENT_BYTES + ( - CONTROL_EVENT_BYTE_RESERVE if allow_control else 0 - ) - gateway_byte_limit = MAX_GATEWAY_EVENT_BYTES + ( - CONTROL_EVENT_BYTE_RESERVE if allow_control else 0 - ) - if int(room["next_seq"]) - 1 >= event_limit: - raise HostedRoomError( - "This Group Chat reached its history limit. Start a new Group Chat to continue." - ) - room_bytes = int(room["event_bytes"]) - if room_bytes + additional_bytes > room_byte_limit: - raise HostedRoomError( - "This Group Chat reached its storage limit. Start a new Group Chat to continue." - ) - gateway_bytes = int( - conn.execute( - "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" - ).fetchone()[0] - ) - if gateway_bytes + additional_bytes > gateway_byte_limit: - _prune_disbanded_rooms_locked( - conn, - now=None, - max_gateway_event_bytes=max(0, gateway_byte_limit - additional_bytes), - ) - gateway_bytes = int( - conn.execute( - "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" - ).fetchone()[0] - ) - if gateway_bytes + additional_bytes > gateway_byte_limit: - raise HostedRoomError( - "Group Chat storage is full on this host. Delete an old Group Chat and try again." - ) - - -def _table_exists(conn: sqlite3.Connection, table: str) -> bool: - return ( - conn.execute( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", - (table,), - ).fetchone() - is not None - ) - - -def _prune_disbanded_rooms_locked( - conn: sqlite3.Connection, - *, - now: float | None, - max_gateway_event_bytes: int | None = None, -) -> int: - candidates: set[str] = set() - if now is not None: - cutoff = now - DISBANDED_ROOM_RETENTION_SECONDS - candidates.update( - str(row["room_id"]) - for row in conn.execute( - """SELECT room_id FROM hosted_rooms - WHERE disbanded_at IS NOT NULL AND disbanded_at<=?""", - (cutoff,), - ).fetchall() - ) - candidates.update( - str(row["room_id"]) - for row in conn.execute( - """SELECT room_id FROM hosted_rooms - WHERE disbanded_at IS NOT NULL - ORDER BY disbanded_at DESC, room_id ASC - LIMIT -1 OFFSET ?""", - (MAX_DISBANDED_ROOM_TOMBSTONES,), - ).fetchall() - ) - if max_gateway_event_bytes is not None: - retained_bytes = int( - conn.execute( - "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" - ).fetchone()[0] - ) - if retained_bytes > max_gateway_event_bytes: - for row in conn.execute( - """SELECT room_id, event_bytes FROM hosted_rooms - WHERE disbanded_at IS NOT NULL - ORDER BY disbanded_at ASC, room_id ASC""" - ).fetchall(): - room_id = str(row["room_id"]) - if room_id not in candidates: - candidates.add(room_id) - retained_bytes -= int(row["event_bytes"]) - if retained_bytes <= max_gateway_event_bytes: - break - if not candidates: - return 0 - - placeholders = ",".join("?" for _ in candidates) - room_ids = tuple(sorted(candidates)) - conn.execute( - f"""INSERT OR IGNORE INTO hosted_room_retired_ids (room_id, retired_at) - SELECT room_id, disbanded_at FROM hosted_rooms - WHERE room_id IN ({placeholders}) AND disbanded_at IS NOT NULL""", - room_ids, - ) - dependent_tables = ( - "hosted_room_policy_transcript_state", - "hosted_room_policy_transcript", - "hosted_room_policy_publications", - "hosted_room_policy_watermarks", - "hosted_room_policy_events", - "hosted_room_policy_threads", - "hosted_room_policy_cursors", - "hosted_room_driver_tasks", - "hosted_room_driver_leases", - "hosted_room_remote_runs", - "hosted_room_links", - "hosted_room_peer_reservations", - "hosted_room_events", - ) - for table in dependent_tables: - if _table_exists(conn, table): - conn.execute( - f"DELETE FROM {table} WHERE room_id IN ({placeholders})", - room_ids, - ) - conn.execute( - f"DELETE FROM hosted_rooms WHERE room_id IN ({placeholders})", - room_ids, - ) - return len(room_ids) - - -def prune_disbanded_rooms( - db_path: Path | str, - *, - now: float | None = None, -) -> int: - """Purge deleted Group Chat payloads while reserving their identities.""" - - timestamp = time.time() if now is None else float(now) - with _transaction(db_path, immediate=True) as conn: - return _prune_disbanded_rooms_locked(conn, now=timestamp) - - -def _event_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: - return { - "room_id": row["room_id"], - "seq": int(row["seq"]), - "event_id": row["event_id"], - "kind": row["kind"], - "actor": json.loads(row["actor_json"]), - "authority_epoch": ( - int(row["authority_epoch"]) if row["authority_epoch"] is not None else None - ), - "payload": json.loads(row["payload_json"]), - "created_at": float(row["created_at"]), - "idempotent": idempotent, - } def create_room( @@ -1436,6 +116,11 @@ def create_room( now = time.time() if now is None else float(now) with _transaction(db_path, immediate=True) as conn: + _raise_if_quarantined(conn, room_id) + if _replica_reserves_room_id_locked(conn, room_id): + raise RoomConflictError("room_id belongs to a passive replica") + if _room_id_reservation_kind_locked(conn, room_id) == "replica": + raise RoomConflictError("room_id belongs to a retired passive replica") if conn.execute( "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", (room_id,), @@ -1604,12 +289,16 @@ def list_rooms( conn = _read_connection(db_path) try: rows = conn.execute( - """SELECT room_id, name, members_json, authority_gateway_id, - authority_epoch, next_seq, revision, created_at, updated_at, - disbanded_at - FROM hosted_rooms - WHERE disbanded_at IS NULL OR ? - ORDER BY updated_at DESC, room_id ASC + """SELECT rooms.room_id, rooms.name, rooms.members_json, + rooms.authority_gateway_id, rooms.authority_epoch, + rooms.next_seq, rooms.revision, rooms.created_at, + rooms.updated_at, rooms.disbanded_at, + quarantine.reason AS quarantine_reason + FROM hosted_rooms AS rooms + LEFT JOIN hosted_room_quarantine AS quarantine + ON quarantine.room_id=rooms.room_id + WHERE rooms.disbanded_at IS NULL OR ? + ORDER BY rooms.updated_at DESC, rooms.room_id ASC LIMIT ? OFFSET ?""", (int(include_disbanded), limit, offset), ).fetchall() @@ -1779,6 +468,7 @@ def append_event( now = time.time() if now is None else float(now) with _transaction(db_path, immediate=True) as conn: + _raise_if_quarantined(conn, room_id) existing = conn.execute( """SELECT room_id, seq, event_id, kind, actor_json, authority_epoch, payload_json, created_at @@ -1970,6 +660,7 @@ def room_state( max_chars=MAX_ROOM_ID_CHARS, ) with _transaction(db_path) as conn: + _raise_if_quarantined(conn, room_id) row = conn.execute( """SELECT room_id, name, members_json, authority_gateway_id, authority_epoch, next_seq, revision, created_at, updated_at, @@ -2086,6 +777,7 @@ def claim_authority( ) with _transaction(db_path, immediate=True) as conn: + _raise_if_quarantined(conn, room_id) row = conn.execute( """SELECT authority_gateway_id, authority_epoch, next_seq, event_bytes FROM hosted_rooms @@ -2220,6 +912,7 @@ def disband_room( now = time.time() if now is None else float(now) with _transaction(db_path, immediate=True) as conn: + _raise_if_quarantined(conn, room_id) room = conn.execute( """SELECT authority_gateway_id, authority_epoch, next_seq, event_bytes, disbanded_at diff --git a/gateway/platforms/api_server_room_controls.py b/gateway/platforms/api_server_room_controls.py new file mode 100644 index 0000000000000..cd77275f77a80 --- /dev/null +++ b/gateway/platforms/api_server_room_controls.py @@ -0,0 +1,368 @@ +"""Private, room-scoped control API for participating RoomLink gateways.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +try: + from aiohttp import web +except ImportError: + web = None # type: ignore[assignment] + +from gateway import hosted_room_controls, hosted_rooms +from gateway.hosted_room_messaging import MessagingRoomBackend + + +MAX_CONTROL_TEXT_CHARS = 64 * 1024 +MAX_CONTROL_EVENTS = 5 +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") + + +def _http_routes(self) -> list[tuple[str, str, Any]]: + async def read(request): + from gateway.platforms import api_server + + return await _handle_room_control_read( + self, + request, + _openai_error=api_server._openai_error, + ) + + async def mutate(request): + from gateway.platforms import api_server + + return await _handle_room_control_mutate( + self, + request, + _openai_error=api_server._openai_error, + ) + + async def revoke(request): + from gateway.platforms import api_server + + return await _handle_room_control_revoke( + self, + request, + _openai_error=api_server._openai_error, + ) + + return [ + ("GET", "/v1/room-controls/{room_id}", read), + ("POST", "/v1/room-controls/{room_id}", mutate), + ("DELETE", "/v1/room-controls/{room_id}", revoke), + ] + + +def _control_token(request: "web.Request") -> str: + authorization = str(request.headers.get("Authorization") or "") + scheme, separator, token = authorization.partition(" ") + if not separator or scheme.casefold() != "hermesroomcontrol": + return "" + return token.strip() + + +def _identifier(value: Any, *, label: str) -> str: + normalized = str(value or "").strip() + if not _IDENTIFIER_RE.fullmatch(normalized): + raise ValueError(f"invalid {label}") + return normalized + + +def _authorize(request: "web.Request") -> tuple[dict[str, Any], str]: + try: + room_id = _identifier(request.match_info.get("room_id"), label="room_id") + member_id = _identifier( + request.headers.get("X-Hermes-Room-Member"), + label="room member", + ) + except ValueError as exc: + raise PermissionError("room control scope is missing") from exc + token = _control_token(request) + if not token: + raise PermissionError("room control credential is missing") + try: + room = hosted_rooms.room_state( + hosted_rooms.default_db_path(), + room_id=room_id, + ) + except hosted_rooms.RoomNotFoundError as exc: + raise PermissionError("room control credential is invalid") from exc + if not hosted_room_controls.verify_home_control_token( + hosted_rooms.default_db_path(), + room_id=room_id, + member_id=member_id, + authority_gateway_id=str(room["authority_gateway_id"]), + authority_epoch=int(room["authority_epoch"]), + control_token=token, + ): + raise PermissionError("room control credential is invalid") + return room, member_id + + +def _backend() -> MessagingRoomBackend: + from tui_gateway.methods_groups import get_hosted_room_service + + service = get_hosted_room_service() + return MessagingRoomBackend( + db_path=(service.db_path if service is not None else hosted_rooms.default_db_path()), + service=service, + ) + + +def _visible_events(room_id: str) -> list[dict[str, Any]]: + state = hosted_rooms.room_state(hosted_rooms.default_db_path(), room_id=room_id) + delta = hosted_rooms.read_events( + hosted_rooms.default_db_path(), + room_id=room_id, + since_seq=max(0, int(state.get("latest_seq") or 0) - 80), + limit=80, + ) + visible: list[dict[str, Any]] = [] + for event in delta.get("events", []): + if not isinstance(event, dict) or event.get("kind") not in { + "message.member", + "message.user", + }: + continue + raw_actor = event.get("actor") + actor = raw_actor if isinstance(raw_actor, Mapping) else {} + raw_payload = event.get("payload") + payload = raw_payload if isinstance(raw_payload, Mapping) else {} + visible.append( + { + "kind": event["kind"], + "actor": { + "id": str(actor.get("id") or "")[:256], + "display_name": str(actor.get("display_name") or "")[:128], + }, + "payload": { + "member_id": str(payload.get("member_id") or "")[:256], + "text": str(payload.get("text") or "")[:MAX_CONTROL_TEXT_CHARS], + }, + } + ) + return visible[-MAX_CONTROL_EVENTS:] + + +def _summary(room: Mapping[str, Any], backend: MessagingRoomBackend) -> dict[str, Any]: + room_id = str(room["room_id"]) + raw_status = backend.status(room_id) + raw_counts = raw_status.get("counts") + counts = raw_counts if isinstance(raw_counts, Mapping) else {} + members = [] + for raw_member in list(room.get("members") or []): + if not isinstance(raw_member, Mapping): + continue + members.append( + { + "member_id": str(raw_member.get("member_id") or "")[:256], + "handle": str(raw_member.get("handle") or "")[:128], + "display_name": str(raw_member.get("display_name") or "")[:128], + } + ) + return { + "room": { + "room_id": room_id, + "name": str(room.get("name") or room_id), + "members": members, + "authority_gateway_id": str(room["authority_gateway_id"]), + "authority_epoch": int(room["authority_epoch"]), + "latest_seq": int(room.get("latest_seq") or 0), + }, + "status": { + "working": raw_status.get("working") is True, + "blocked": raw_status.get("blocked") is True, + "counts": { + status: int(counts.get(status) or 0) + for status in ( + "queued", + "running", + "stopping", + "deferred", + "indeterminate", + "settled", + "failed", + "cancelled", + ) + if int(counts.get(status) or 0) > 0 + }, + }, + "events": _visible_events(room_id), + } + + +def _error_response(_openai_error, message: str, *, status: int, code: str): + return web.json_response( + _openai_error(message, code=code), + status=status, + ) + + +async def _handle_room_control_read( + self, + request: "web.Request", + *, + _openai_error, +) -> "web.Response": + try: + room, _member_id = _authorize(request) + result = _summary(room, _backend()) + except PermissionError: + return _error_response( + _openai_error, + "Room control is unavailable or expired.", + status=401, + code="invalid_room_control", + ) + except hosted_rooms.RoomNotFoundError: + return _error_response( + _openai_error, + "Group Chat not found.", + status=404, + code="room_not_found", + ) + except Exception: + return _error_response( + _openai_error, + "Group Chat status could not be loaded.", + status=409, + code="room_control_unavailable", + ) + return web.json_response(result) + + +async def _handle_room_control_mutate( + self, + request: "web.Request", + *, + _openai_error, +) -> "web.Response": + try: + room, member_id = _authorize(request) + body, body_error = await self._read_json_body(request) + if body_error: + return body_error + if not isinstance(body, Mapping): + raise ValueError("room control body must be an object") + allowed = {"action", "actor_display_name", "command_id", "text"} + if set(body) - allowed or not {"action", "command_id"} <= set(body): + raise ValueError("room control fields are invalid") + action = str(body.get("action") or "").casefold() + command_id = _identifier(body.get("command_id"), label="command_id") + room_id = str(room["room_id"]) + backend = _backend() + display_name = str(body.get("actor_display_name") or "Messaging").strip() + display_name = re.sub(r"\s+", " ", display_name)[:128] or "Messaging" + + if action == "send": + text = str(body.get("text") or "").strip() + if not text or len(text) > MAX_CONTROL_TEXT_CHARS: + raise ValueError("Group Chat message is empty or too large") + event = backend.send( + room_id=room_id, + event_id=command_id, + payload={"text": text, "thread_id": command_id}, + actor={ + "kind": "user", + "id": f"peer:{member_id}", + "display_name": display_name, + }, + ) + result = {"action": "send", "event": event} + elif action == "stop": + cancelled = backend.stop_room(room_id, cancel_id=f"control:{command_id}") + result = {"action": "stop", "cancelled": int(cancelled)} + elif action == "retry": + pending = [ + str(item.get("task_id") or "") + for item in backend.status(room_id).get("pending_actions", []) + if isinstance(item, Mapping) + and item.get("kind") == "retry" + and str(item.get("task_id") or "") + ][:8] + plan = hosted_room_controls.begin_control_retry( + hosted_rooms.default_db_path(), + command_id=command_id, + room_id=room_id, + member_id=member_id, + task_ids=pending, + ) + if plan.result is not None: + result = plan.result + else: + result = { + "action": "retry", + "queued": True, + "retried": len(plan.task_ids), + } + else: + raise ValueError("room control action must be send, retry, or stop") + except PermissionError: + return _error_response( + _openai_error, + "Room control is unavailable or expired.", + status=401, + code="invalid_room_control", + ) + except hosted_rooms.RoomNotFoundError: + return _error_response( + _openai_error, + "Group Chat not found.", + status=404, + code="room_not_found", + ) + except (ValueError, hosted_room_controls.HostedRoomControlError) as exc: + return _error_response( + _openai_error, + str(exc), + status=400, + code="invalid_room_control", + ) + except Exception: + return _error_response( + _openai_error, + "Group Chat control could not be applied.", + status=409, + code="room_control_unavailable", + ) + return web.json_response({**result, "summary": _summary(room, backend)}) + + +async def _handle_room_control_revoke( + self, + request: "web.Request", + *, + _openai_error, +) -> "web.Response": + try: + room_id = _identifier(request.match_info.get("room_id"), label="room_id") + member_id = _identifier( + request.headers.get("X-Hermes-Room-Member"), + label="room member", + ) + token = _control_token(request) + if not token: + raise PermissionError("room control credential is missing") + revoked = hosted_room_controls.revoke_home_control_token_value( + hosted_rooms.default_db_path(), + room_id=room_id, + member_id=member_id, + control_token=token, + ) + except (PermissionError, ValueError, hosted_room_controls.HostedRoomControlError): + return _error_response( + _openai_error, + "Room control is unavailable or expired.", + status=401, + code="invalid_room_control", + ) + except Exception: + return _error_response( + _openai_error, + "Group Chat control could not be revoked.", + status=409, + code="room_control_unavailable", + ) + return web.json_response({"revoked": int(revoked)}) diff --git a/gateway/platforms/api_server_room_grants.py b/gateway/platforms/api_server_room_grants.py index 6acb4c37463b1..eff92852bf3c9 100644 --- a/gateway/platforms/api_server_room_grants.py +++ b/gateway/platforms/api_server_room_grants.py @@ -1,5 +1,6 @@ """RoomLink room-member grants and capability HTTP handlers.""" +import asyncio import time import uuid from typing import Any @@ -49,7 +50,7 @@ def _room_grant_error_response(exc: Exception, *, _openai_error) -> "web.Respons def _http_routes(self) -> list[tuple[str, str, Any]]: - return [ + routes = [ ( "POST", "/v1/room-members/invitations", @@ -71,6 +72,10 @@ def _http_routes(self) -> list[tuple[str, str, Any]]: self._handle_room_member_grant_revoke, ), ] + from gateway.platforms import api_server_room_controls + + routes.extend(api_server_room_controls._http_routes(self)) + return routes def _room_grant_token(request: "web.Request") -> str: @@ -391,6 +396,7 @@ async def _handle_room_member_grant_revoke( self._room_grant_secret(), token, permission="status", + allow_expired_for_revocation=True, ) profile = _api_request_profile.get() or "default" installation_id = hosted_rooms.local_authority_gateway_id() @@ -415,6 +421,23 @@ async def _handle_room_member_grant_revoke( ), status=401, ) + try: + from gateway.hosted_room_control_client import revoke_stored_peer_control + + await asyncio.to_thread( + revoke_stored_peer_control, + hosted_rooms.default_db_path(), + room_id=str(claims["room_id"]), + member_id=str(claims["member_id"]), + ) + except Exception: + return web.json_response( + _openai_error( + "Room control cleanup is pending; retry this revocation.", + code="room_control_cleanup_pending", + ), + status=503, + ) return web.json_response( { "object": "hermes.room_member.grant.revocation", diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 4e46f2b2b2d9a..560e459806d62 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -742,6 +742,9 @@ async def _handle_envelope(self, envelope: dict) -> None: user_id_alt=sender_uuid if sender_uuid else None, chat_id_alt=group_id if is_group else None, ) + source.is_one_to_one = not is_group + source.message_is_edit = envelope_data.get("editMessage") is not None + source.message_had_attachments = bool(attachments_data) # Determine message type from media msg_type = MessageType.TEXT diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py index 09e7b0b64f886..24750cb6784aa 100644 --- a/gateway/platforms/whatsapp_common.py +++ b/gateway/platforms/whatsapp_common.py @@ -495,7 +495,6 @@ def _header_to_bold(m: re.Match) -> str: result = result.replace(f"{_FENCE_PH}{i}\x00", fence) for i, code in enumerate(codes): result = result.replace(f"{_CODE_PH}{i}\x00", code) - return result diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index 5e90887c57a4e..b49130028a7e6 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -289,6 +289,7 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: scope_id=src.get("scope_id"), parent_chat_id=src.get("parent_chat_id"), message_id=src.get("message_id"), + is_bot=src.get("is_bot") if isinstance(src.get("is_bot"), bool) else False, # The HERMES profile this event is routed to (multiplex mode). The # connector stamps it on the wire source when NAS resolves the target # profile for a Team-Gateway message; absent for a single-profile @@ -319,6 +320,24 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: # ``Platform.RELAY``). Stamped here, never read off the wire. delivered_via_upstream_relay=True, ) + verified_one_to_one = src.get("one_to_one_verified") + if isinstance(verified_one_to_one, bool): + # The connector is authenticated to this relay socket, so this wire-only + # proof becomes a transport-local fact and is never persisted/replayed. + source.is_one_to_one = verified_one_to_one + elif str(src.get("chat_type") or "").casefold() in {"dm", "direct", "private"} and platform_enum in { + Platform.DISCORD, + Platform.SIGNAL, + Platform.TELEGRAM, + Platform.WHATSAPP, + Platform.WHATSAPP_CLOUD, + }: + # These adapters use a distinct non-DM chat type for multi-party chats. + # Slack MPIM and Matrix m.direct are deliberately excluded. + source.is_one_to_one = True + relay_edit = src.get("message_is_edit") + if isinstance(relay_edit, bool): + source.message_is_edit = relay_edit try: msg_type = MessageType(raw.get("message_type", "text")) except ValueError: @@ -336,6 +355,10 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: text=text, message_type=msg_type, source=source, + metadata={ + "relay_author_classified": isinstance(src.get("is_bot"), bool), + "relay_edit_classified": isinstance(relay_edit, bool), + }, message_id=raw.get("message_id"), reply_to_message_id=raw.get("reply_to_message_id"), # Richer quoted-reply context (Phase 4): what the user replied TO, diff --git a/gateway/run.py b/gateway/run.py index 0eaf7bea64acb..8d800f0dceec2 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1169,6 +1169,44 @@ def _clarify_send_then_wait(fut, *, clarify_id: str, session_key: str, clarify_m # Timeout or session-boundary cancellation return f"[user did not respond within {int(timeout / 60)}m]" return response +_SESSION_TURN_LEASE_REFRESH_RE = None + + +def _session_turn_lease_refresh_re(): + """Compile-once matcher for run_agent's periodic lease-wait refresh. + + Derived from the SAME template constant the emit site formats + (SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE, #89166) — never + re-inlined wording, same convention as _COMPRESSION_PROGRESS_STATUS_RE + (#69550). The import stays lazy because gateway/run.py never imports + run_agent at module scope. + """ + global _SESSION_TURN_LEASE_REFRESH_RE + if _SESSION_TURN_LEASE_REFRESH_RE is None: + from run_agent import SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE + + _SESSION_TURN_LEASE_REFRESH_RE = re.compile( + _status_template_to_regex(SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE), + re.IGNORECASE, + ) + return _SESSION_TURN_LEASE_REFRESH_RE + + +def _should_suppress_lease_wait_refresh(adapter, message: str) -> bool: + """True when a periodic turn-lease wait refresh would flood this adapter. + + The periodic refresh exists to update the initial "Another Hermes process + is using this session..." notice in place. On adapters without + ``send_or_update_status`` (WeCom, Weixin, QQ, Signal, ... — all + SUPPORTS_MESSAGE_EDITING = False) ``_send_or_update_status_coro`` falls + back to a plain send, so every ~15s refresh lands as another standalone + chat message (#89166). Suppress the refresh there; the initial notice and + the lease-timeout warning use different wording and are always delivered. + """ + if callable(getattr(adapter, "send_or_update_status", None)): + return False + return bool(_session_turn_lease_refresh_re().search(str(message or ""))) + def _resolve_progress_thread_id( @@ -5759,6 +5797,15 @@ def _status_callback_sync(self, event_type: str, message: str) -> None: _redact_gateway_user_facing_secrets(str(message or ""))[:160], ) return + if _should_suppress_lease_wait_refresh( + ctx._status_adapter, prepared_message + ): + logger.debug( + "suppressed periodic lease-wait refresh for %s: adapter has " + "no in-place status updates", + ctx.source.platform.value if ctx.source.platform else "unknown", + ) + return _fut = safe_schedule_threadsafe( _send_or_update_status_coro(ctx._status_adapter, ctx._status_chat_id, event_type, prepared_message, ctx._status_thread_metadata), ctx._loop_for_step, @@ -17799,6 +17846,7 @@ def _gateway_plain_command_handlers(self): "bg": self._handle_background_command, "btw": self._handle_btw_command, "kanban": self._handle_kanban_command, + "group": self._handle_rooms_command, "subgoal": self._handle_subgoal_command, "heartbeat": self._handle_heartbeat_command, "busy": self._handle_busy_command, @@ -19167,6 +19215,12 @@ async def _do_reset(): if canonical == "personality": return await self._handle_personality_command(event) + if canonical == "kanban": + return await self._handle_kanban_command(event) + + if canonical == "group": + return await self._handle_rooms_command(event) + if canonical == "suggestions": return await self._handle_suggestions_command(event) diff --git a/gateway/session.py b/gateway/session.py index c13183056bfbe..fba107cca94f8 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -185,6 +185,10 @@ class SessionSource: # Transport-local fail-closed signal for an explicit profile route whose # target is not served. Excluded from repr/equality and wire serialization. profile_route_rejected: bool = field(default=False, repr=False, compare=False) + # Transport-local trust facts; never deserialize these from stored/wire data. + is_one_to_one: Optional[bool] = field(default=None, repr=False, compare=False) + message_is_edit: bool = field(default=False, repr=False, compare=False) + message_had_attachments: bool = field(default=False, repr=False, compare=False) # Discord auto-thread metadata. Newly auto-created Discord threads start # with a fast placeholder title from the raw message, then the gateway can @@ -260,6 +264,7 @@ def to_dict(self) -> Dict[str, Any]: "user_name": self.user_name, "thread_id": self.thread_id, "chat_topic": self.chat_topic, + "is_bot": self.is_bot, } if self.user_id_alt: d["user_id_alt"] = self.user_id_alt @@ -305,6 +310,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": scope_id=data.get("scope_id", data.get("guild_id")), parent_chat_id=data.get("parent_chat_id"), message_id=data.get("message_id"), + is_bot=bool(data.get("is_bot", False)), profile=data.get("profile"), auto_thread_created=bool(data.get("auto_thread_created", False)), auto_thread_initial_name=data.get("auto_thread_initial_name"), diff --git a/gateway/slash_access.py b/gateway/slash_access.py index e4a398dc14a19..c0732636730c7 100644 --- a/gateway/slash_access.py +++ b/gateway/slash_access.py @@ -222,8 +222,60 @@ def policy_for_source(gateway_config: Any, source: Any) -> SlashAccessPolicy: return policy_from_extra(extra, scope) +def is_home_control_source(gateway_config: Any, source: Any) -> bool: + """Return whether *source* is the configured home chat's exact operator. + + The home chat is the operator-selected control surface (``/sethome``). + Matching it here avoids making that same operator maintain a second + slash-admin allowlist for owner-only controls. A shared home chat is valid + only when ``/sethome`` stored the selecting user's identity; other members + never inherit that authority. + """ + if gateway_config is None or source is None: + return False + scope = _scope_for_chat_type(getattr(source, "chat_type", None)) + if scope not in {"dm", "group"}: + return False + if getattr(source, "is_bot", False) is True: + return False + + platform = getattr(source, "platform", None) + user_id = getattr(source, "user_id", None) + chat_id = getattr(source, "chat_id", None) + if platform is None or not user_id or not chat_id: + return False + + try: + home = gateway_config.get_home_channel(platform) + except Exception: + return False + if home is None or str(home.chat_id) != str(chat_id): + return False + + home_user_id = getattr(home, "user_id", None) + if scope == "group" and not home_user_id: + return False + if home_user_id and str(home_user_id) != str(user_id): + return False + home_scope_id = getattr(home, "scope_id", None) + source_scope_id = getattr(source, "scope_id", None) + if home_scope_id and str(home_scope_id) != str(source_scope_id or ""): + return False + return True + + +def is_home_dm_source(gateway_config: Any, source: Any) -> bool: + """Backward-compatible DM-only wrapper for existing callers.""" + + if _scope_for_chat_type(getattr(source, "chat_type", None)) != "dm": + return False + return is_home_control_source(gateway_config, source) + + __all__ = [ "SlashAccessPolicy", "policy_from_extra", "policy_for_source", + "is_home_control_source", + "is_home_dm_source", ] diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 41b829f6d8c33..a10cc55c4ba88 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -55,6 +55,26 @@ # its worker thread. (#35994) _RESET_CLEANUP_TIMEOUT_S = 30.0 +_GROUP_CHAT_RATE_WINDOW_SECONDS = 60.0 +_GROUP_CHAT_READ_RATE_LIMIT = 30 +_GROUP_CHAT_MUTATION_RATE_LIMIT = 12 +_GROUP_CHAT_STOP_RATE_LIMIT = 30 +_GROUP_CHAT_RATE_BUCKET_CAP = 2048 +_NATIVE_DISTINCT_DM_PLATFORMS = frozenset({ + "bluebubbles", + "dingtalk", + "email", + "feishu", + "mattermost", + "qqbot", + "sms", + "wecom", + "wecom_callback", + "weixin", + "whatsapp_cloud", + "yuanbao", +}) + def _clean_str(value: Any) -> str: """Strip and return a non-empty string value, or empty string.""" @@ -573,6 +593,531 @@ def _sub(): output = output[:3800] + "\n" + t("gateway.kanban.truncated_suffix") return output or t("gateway.kanban.no_output") + def _home_chat_is_single_operator(self, event: MessageEvent) -> bool: + """Recognize the configured home chat's exact authorized operator.""" + from gateway.authz_mixin import ( + _auth_env, + _coerce_allow_set, + _platform_authorization_env_names, + ) + from gateway.slash_access import is_home_control_source + + source = event.source + if getattr(source, "delivered_via_upstream_relay", False) is True: + return False + if not is_home_control_source(self.config, source): + return False + + is_authorized = getattr(self, "_is_user_authorized_for_source", None) + if not callable(is_authorized): + return False + try: + if not is_authorized(source): + return False + except Exception: + return False + + def _census() -> bool: + platform_name = source.platform.value + allowed_users_env, allow_all_env = _platform_authorization_env_names( + source.platform + ) + candidates: set[str] = set() + adapter_for_source = getattr(self, "_adapter_for_source", None) + transport_adapter = ( + adapter_for_source(source) if callable(adapter_for_source) else None + ) + adapter_config = getattr(transport_adapter, "config", None) + adapter_extra = getattr(adapter_config, "extra", None) + if transport_adapter is not None: + extra = adapter_extra if isinstance(adapter_extra, dict) else {} + else: + platform_config = self.config.platforms.get(source.platform) + extra = getattr(platform_config, "extra", None) or {} + candidates.update(_coerce_allow_set(extra.get("allow_from"))) + candidates.update(_coerce_allow_set(_auth_env(allowed_users_env))) + candidates.update(_coerce_allow_set(_auth_env("GATEWAY_ALLOWED_USERS"))) + if _auth_env("GATEWAY_ALLOW_ALL_USERS").lower() in { + "true", + "1", + "yes", + }: + return False + if allow_all_env and _auth_env(allow_all_env).lower() in { + "true", + "1", + "yes", + }: + return False + + authorization_home = getattr( + source, + "_authorization_profile_home", + None, + ) + if authorization_home is not None: + pairing_store = getattr(self, "pairing_store", None) + else: + pairing_store_for = getattr(self, "_pairing_store_for", None) + pairing_store = ( + pairing_store_for(source) + if callable(pairing_store_for) + else None + ) + if pairing_store is not None: + try: + candidates.update( + str(row.get("user_id") or "").strip() + for row in pairing_store.list_approved(platform_name) + if str(row.get("user_id") or "").strip() + ) + except Exception: + return False + if not candidates or "*" in candidates: + return False + + user_id = str(source.user_id) + matcher = getattr(pairing_store, "_user_ids_match", None) + if callable(matcher): + return all( + matcher(platform_name, candidate, user_id) + for candidate in candidates + ) + return candidates == {user_id} + + authorization_home = getattr(source, "_authorization_profile_home", None) + if authorization_home is None: + return _census() + from gateway.run import _profile_runtime_scope + + with _profile_runtime_scope(Path(authorization_home)): + return _census() + + def _can_control_group_chats(self, event: MessageEvent) -> bool: + """Authorize a trusted DM or the exact operator of an explicit home chat.""" + from gateway.slash_access import policy_for_source + + if self._home_chat_is_single_operator(event): + return True + # ``chat_type=dm`` is not a privacy boundary: Slack MPIMs and Matrix + # m.direct rooms can contain several people. Adapters stamp this + # transport-local signal only when they can prove the current surface is + # one-to-one. Unknown and older connectors fail closed. + platform = str(getattr(getattr(event.source, "platform", None), "value", "") or "") + native_distinct_dm = ( + getattr(event.source, "delivered_via_upstream_relay", False) is not True + and platform in _NATIVE_DISTINCT_DM_PLATFORMS + and str(getattr(event.source, "chat_type", "") or "").casefold() + in {"dm", "direct", "private"} + ) + if getattr(event.source, "is_one_to_one", None) is not True and not native_distinct_dm: + return False + chat_type = str(getattr(event.source, "chat_type", "") or "").casefold() + if chat_type not in {"", "dm", "direct", "private"}: + return False + policy = policy_for_source(self.config, event.source) + return policy.enabled and policy.is_admin(event.source.user_id) + + @staticmethod + def _group_chat_control_denial(event: MessageEvent) -> str: + chat_type = str(getattr(event.source, "chat_type", "") or "").casefold() + platform = str(getattr(getattr(event.source, "platform", None), "value", "") or "") + proven_private = getattr(event.source, "is_one_to_one", None) is True or ( + getattr(event.source, "delivered_via_upstream_relay", False) is not True + and platform in _NATIVE_DISTINCT_DM_PLATFORMS + and chat_type in {"dm", "direct", "private"} + ) + if ( + chat_type not in {"", "dm", "direct", "private"} + or not proven_private + ): + return ( + "Group Chat controls are private. Use your authorized one-to-one " + "Hermes chat." + ) + return ( + "This chat can’t control Group Chats. Use your authorized one-to-one " + "Hermes chat or authorize this account in settings." + ) + + def _group_chat_rate_limit_denial( + self, + event: MessageEvent, + *, + action: str, + ) -> Optional[str]: + """Bound authenticated Group Chat commands per person and chat.""" + + normalized_action = str(action or "read").casefold() + if normalized_action == "stop": + limit = _GROUP_CHAT_STOP_RATE_LIMIT + bucket_kind = "stop" + elif normalized_action in {"send", "retry"}: + limit = _GROUP_CHAT_MUTATION_RATE_LIMIT + bucket_kind = "change" + else: + limit = _GROUP_CHAT_READ_RATE_LIMIT + bucket_kind = "read" + + source = event.source + platform = str(getattr(getattr(source, "platform", None), "value", "") or "") + key = ( + platform, + str(getattr(source, "scope_id", None) or ""), + str(getattr(source, "chat_id", None) or ""), + str(getattr(source, "user_id_alt", None) or getattr(source, "user_id", None) or ""), + bucket_kind, + ) + now = time.monotonic() + buckets = getattr(self, "_group_chat_command_rate_buckets", None) + if not isinstance(buckets, dict): + buckets = {} + self._group_chat_command_rate_buckets = buckets + recent = [ + stamp + for stamp in buckets.get(key, ()) + if now - stamp < _GROUP_CHAT_RATE_WINDOW_SECONDS + ] + if len(recent) >= limit: + buckets[key] = recent + return "Too many Group Chat commands. Wait a moment and try again." + recent.append(now) + buckets[key] = recent + + if len(buckets) > _GROUP_CHAT_RATE_BUCKET_CAP: + stale_before = now - _GROUP_CHAT_RATE_WINDOW_SECONDS + for bucket_key in list(buckets): + if not buckets[bucket_key] or buckets[bucket_key][-1] <= stale_before: + buckets.pop(bucket_key, None) + while len(buckets) > _GROUP_CHAT_RATE_BUCKET_CAP: + buckets.pop(next(iter(buckets))) + return None + + @staticmethod + def _group_chat_profile(event: MessageEvent) -> str: + """Return the profile selected by the authenticated inbound route.""" + + routed = str(getattr(event.source, "profile", None) or "").strip() + if routed: + return routed + from hermes_cli.profiles import get_active_profile_name + + return str(get_active_profile_name() or "default") + + async def _handle_rooms_command(self, event: MessageEvent) -> Optional[str]: + """List Bot Group Chats or show one chat's recent activity.""" + + from gateway import hosted_rooms + from gateway.hosted_room_messaging import ( + RoomControlError, + current_room_backend, + format_room_bot_detail, + format_room_bot_list, + format_room_detail, + format_room_list, + is_message_edit, + is_machine_authored, + list_messaging_rooms, + relay_provenance_is_unknown, + resolve_room, + resolve_room_picker_choice, + room_bot_picker_choices, + room_picker_choices, + ) + + if is_machine_authored(event): + return "Group Chat controls are only available to people." + if is_message_edit(event): + return "Edited messages can’t run Group Chat commands. Send a new message." + if relay_provenance_is_unknown(event): + return ( + "Group Chat controls need a relay connector that reports whether the " + "sender is a person or a bot. Update the connector and try again." + ) + if not self._can_control_group_chats(event): + return self._group_chat_control_denial(event) + service = current_room_backend() + rooms_command = f"{self._typed_command_prefix_for(event.source.platform)}group" + query = event.get_command_args().strip() + try: + words = query.split() + if ( + words + and words[0].isdecimal() + and len(words) > 1 + and words[1].casefold() in {"retry", "send", "stop"} + ): + return await self._handle_room_command(event) + denial = self._group_chat_rate_limit_denial(event, action="read") + if denial: + return denial + profile = self._group_chat_profile(event) + rooms = await asyncio.to_thread( + list_messaging_rooms, + service, + profile=profile, + ) + if ( + len(words) >= 2 + and words[0].isdecimal() + and words[1].casefold() in {"bot", "bots"} + ): + room = resolve_room(rooms, words[0]) + if words[1].casefold() == "bot": + if len(words) != 3: + return f"Use `{rooms_command} {words[0]} bot `." + return await asyncio.to_thread( + format_room_bot_detail, + service, + room, + words[2], + room_command=rooms_command, + ) + if len(words) != 2: + return f"Use `{rooms_command} {words[0]} bots`." + choices = await asyncio.to_thread( + room_bot_picker_choices, + service, + room, + ) + source = await asyncio.to_thread( + self._normalize_source_for_session_key, + event.source, + ) + session_key = self._session_key_for_source(source) + + async def _on_bot_selected(_chat_id: str, value: str) -> str: + if not self._can_control_group_chats(event): + return self._group_chat_control_denial(event) + current_denial = self._group_chat_rate_limit_denial( + event, + action="read", + ) + if current_denial: + return current_denial + try: + current_rooms = await asyncio.to_thread( + list_messaging_rooms, + service, + profile=profile, + ) + current_room = resolve_room(current_rooms, words[0]) + return await asyncio.to_thread( + format_room_bot_detail, + service, + current_room, + value, + room_command=rooms_command, + ) + except (RoomControlError, hosted_rooms.HostedRoomError) as exc: + return str(exc) + except Exception: + logger.exception("Failed to open Group Chat Bot from messaging") + return ( + "Couldn’t load that Bot. " + f"Run `{rooms_command} {words[0]} bots` again." + ) + + picker_sent = await self._try_send_choice_picker( + event, + session_key, + title=( + "🤖 Bots\n" + "Choose a Bot to see its handle and available controls." + ), + choices=choices, + on_choice_selected=_on_bot_selected, + ) + if picker_sent: + return None + return await asyncio.to_thread( + format_room_bot_list, + service, + room, + room_command=rooms_command, + ) + if not query: + choices = await asyncio.to_thread( + room_picker_choices, + service, + rooms, + ) + source = await asyncio.to_thread( + self._normalize_source_for_session_key, + event.source, + ) + session_key = self._session_key_for_source(source) + + async def _on_room_selected(_chat_id: str, value: str) -> str: + if not self._can_control_group_chats(event): + return self._group_chat_control_denial(event) + current_denial = self._group_chat_rate_limit_denial( + event, + action="read", + ) + if current_denial: + return current_denial + try: + current_rooms = await asyncio.to_thread( + list_messaging_rooms, + service, + profile=profile, + ) + selected = resolve_room_picker_choice(current_rooms, value) + return await asyncio.to_thread( + format_room_detail, + service, + selected, + room_command=rooms_command, + ) + except (RoomControlError, hosted_rooms.HostedRoomError) as exc: + return str(exc) + except Exception: + logger.exception("Failed to open Group Chat from messaging picker") + return ( + "Couldn’t load that Group Chat. " + f"Run `{rooms_command}` again." + ) + + picker_sent = await self._try_send_choice_picker( + event, + session_key, + title=( + "👥 Group Chats\n" + "Choose a recent Group Chat to see its status, Bots, activity, and actions. " + f"All: {rooms_command} list" + ), + choices=choices, + on_choice_selected=_on_room_selected, + ) + if picker_sent: + return None + exact_name = next( + ( + room + for room in rooms + if str(room.get("name") or "").casefold() == query.casefold() + ), + None, + ) + if exact_name is not None: + return await asyncio.to_thread( + format_room_detail, + service, + exact_name, + room_command=rooms_command, + ) + list_parts = query.casefold().split() + if not query or (list_parts and list_parts[0] == "list"): + if len(list_parts) > 2 or (len(list_parts) == 2 and not list_parts[1].isdecimal()): + return f"Use `{rooms_command} list [page]`." + page = int(list_parts[1]) if len(list_parts) == 2 else 1 + return await asyncio.to_thread( + format_room_list, + service, + rooms=rooms, + rooms_command=rooms_command, + page=page, + ) + + def _detail() -> str: + room = resolve_room(rooms, query) + return format_room_detail( + service, + room, + room_command=rooms_command, + ) + + return await asyncio.to_thread(_detail) + except (RoomControlError, hosted_rooms.HostedRoomError) as exc: + return str(exc) + except Exception: + logger.exception("Failed to read Bot Group Chats from messaging") + return "Couldn’t load Group Chats. Try again in a moment." + + async def _handle_room_command(self, event: MessageEvent) -> str: + """Send to or stop work in a Bot Group Chat.""" + + from gateway import hosted_rooms + from gateway.hosted_room_messaging import ( + RoomControlError, + current_room_backend, + parse_room_command, + resolve_room, + room_reference, + retry_room, + send_to_room, + stop_room, + is_message_edit, + is_machine_authored, + list_messaging_rooms, + relay_provenance_is_unknown, + ) + if is_machine_authored(event): + return "Group Chat controls are only available to people." + if is_message_edit(event): + return "Edited messages can’t run Group Chat commands. Send a new message." + if relay_provenance_is_unknown(event): + return ( + "Group Chat controls need a relay connector that reports whether the " + "sender is a person or a bot. Update the connector and try again." + ) + if not self._can_control_group_chats(event): + return self._group_chat_control_denial(event) + service = current_room_backend() + rooms_command = f"{self._typed_command_prefix_for(event.source.platform)}group" + try: + command = parse_room_command( + event.get_command_args(), + command_root=rooms_command, + ) + denial = self._group_chat_rate_limit_denial( + event, + action=command.action, + ) + if denial: + return denial + if not command.room_query.isdecimal(): + if command.action == "send": + raise RoomControlError( + f"Use `{rooms_command} send `." + ) + raise RoomControlError( + f"Use `{rooms_command} stop`." + ) + + def _mutate() -> str: + room = resolve_room( + list_messaging_rooms( + service, + profile=self._group_chat_profile(event), + ), + command.room_query, + ) + if ( + room.get("_room_mode") == "desktop" + and str(room.get("room_id") or "").startswith("name:") + ): + raise RoomControlError( + "Open this older Group Chat once in the latest Hermes Desktop " + "before changing it from messaging." + ) + if command.action == "send": + result = send_to_room(service, room, event, command.message) + return f"{result} Check: `{rooms_command} {room_reference(room)}`." + if command.action == "retry": + result = retry_room(service, room, event) + return f"{result} Check: `{rooms_command} {room_reference(room)}`." + result = stop_room(service, room, event) + return f"{result} Check: `{rooms_command} {room_reference(room)}`." + + return await asyncio.to_thread(_mutate) + except (RoomControlError, hosted_rooms.HostedRoomError) as exc: + return str(exc) + except Exception: + logger.exception("Failed to control hosted Bot room from messaging") + return "Couldn’t update that Bot room. Try again in a moment." + async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model @@ -3878,9 +4423,12 @@ async def _try_send_choice_picker( if not has_picker: return False try: - metadata = self._thread_metadata_for_source( + metadata = dict(self._thread_metadata_for_source( event.source, self._reply_anchor_for_event(event) - ) + ) or {}) + requester_user_id = getattr(event.source, "user_id", None) + if requester_user_id is not None: + metadata["requester_user_id"] = str(requester_user_id) result = await adapter.send_choice_picker( chat_id=event.source.chat_id, title=title, diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 23af46886d51f..6bfc51dbc4603 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -203,6 +203,10 @@ class CommandDef: args_hint="", busy_policy="dispatch"), CommandDef("agents", "Show active agents and running tasks", "Session", aliases=("tasks",), busy_policy="dispatch"), + CommandDef("group", "List, inspect, or control Bot Group Chats", "Bots", + gateway_only=True, + args_hint="[list [page] | number | number send message | number retry | number stop]", + busy_policy="dispatch"), CommandDef("journey", "Open the learning journey timeline", "Session", aliases=("learning", "memory-graph"), cli_only=True, args_hint="[list|delete |edit ]", @@ -1478,7 +1482,7 @@ def discord_skill_commands_by_category( # (session export is an interactive surface; platform is a rare # informational lookup) — without this entry /save tips the registry # past the 50-cap and silently clamps /platform, breaking parity. -_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "review", "pause", "whoami", "platform", "insights"}) +_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "review", "pause", "whoami", "platform", "insights", "group"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 79268849df51d..d090aeaf4fcb1 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -6431,6 +6431,8 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes thread_id=thread_id, chat_topic=chat_topic, ) + source.is_one_to_one = is_dm + source.message_is_edit = False msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT channel_id = str(interaction.channel_id) @@ -7891,8 +7893,10 @@ async def send_choice_picker( if not channel: channel = await self._client.fetch_channel(int(target_id)) + navigation = any(choice.get("full_width") for choice in choices) + first_line = title.splitlines()[0] if title else "Choose an option" embed = discord.Embed( - title="⚙ " + (title.splitlines()[0] if title else "Choose an option"), + title=first_line if navigation else f"⚙ {first_line}", description="\n".join(title.splitlines()[1:]) or None, color=discord.Color.blue(), ) @@ -7902,6 +7906,9 @@ async def send_choice_picker( on_choice_selected=on_choice_selected, allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, + requester_user_id=str((metadata or {}).get("requester_user_id") or "") + or None, + navigation=navigation, ) msg = await channel.send(embed=embed, view=view) @@ -8320,6 +8327,8 @@ async def _handle_message( or self._derive_auto_thread_name(message.content or "") ) if auto_threaded_channel is not None else None, ) + source.is_one_to_one = isinstance(message.channel, discord.DMChannel) + source.message_is_edit = getattr(message, "edited_at", None) is not None # Build media URLs -- download image attachments to local cache so the # vision tool can access them reliably (Discord CDN URLs can expire). @@ -9568,12 +9577,16 @@ def __init__( on_choice_selected, allowed_user_ids: set, allowed_role_ids: Optional[set] = None, + requester_user_id: Optional[str] = None, + navigation: bool = False, ): super().__init__(timeout=120) self.choices = list(choices)[:_DISCORD_SELECT_MAX_OPTIONS] self.on_choice_selected = on_choice_selected self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() + self.requester_user_id = requester_user_id + self.navigation = navigation self.resolved = False self._message = None @@ -9597,6 +9610,10 @@ def __init__( self.add_item(select) def _check_auth(self, interaction: discord.Interaction) -> bool: + if self.requester_user_id and self.requester_user_id != str( + getattr(getattr(interaction, "user", None), "id", "") + ): + return False return _component_check_auth( interaction, self.allowed_user_ids, self.allowed_role_ids, ) @@ -9604,7 +9621,11 @@ def _check_auth(self, interaction: discord.Interaction) -> bool: async def _on_select(self, interaction: discord.Interaction): if not self._check_auth(interaction): await interaction.response.send_message( - "⛔ You are not authorized to change this setting.", + ( + "⛔ You are not authorized to use this menu." + if self.navigation + else "⛔ You are not authorized to change this setting." + ), ephemeral=True, ) return @@ -9624,7 +9645,11 @@ async def _on_select(self, interaction: discord.Interaction): embed = discord.Embed( description=result_text, - color=discord.Color.green(), + color=( + discord.Color.blue() + if self.navigation + else discord.Color.green() + ), ) self.clear_items() self.stop() @@ -9637,7 +9662,11 @@ async def on_timeout(self): if msg is not None: try: embed = discord.Embed( - description="⏱ Selection expired — no change made.", + description=( + "⏱ Menu expired — run the command again." + if self.navigation + else "⏱ Selection expired — no change made." + ), color=discord.Color.greyple(), ) self.clear_items() diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 3268fd9d1930d..7f04e35abd715 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -3457,7 +3457,15 @@ async def _resolve_message_context( guild_id=identity.server_name, parent_chat_id=room_id if thread_id else None, message_id=event_id, + is_bot=bool(sender and sender == self._user_id), ) + joined_member_count = getattr(identity, "joined_member_count", None) + source.is_one_to_one = bool( + chat_type == "dm" + and joined_member_count is not None + and joined_member_count <= 2 + ) + source.message_is_edit = False if thread_id: self._threads.mark(thread_id) diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index 6962fbf615075..3712803ecdd0d 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -994,6 +994,7 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None: thread_id=thread_id, message_id=post_id, ) + source.message_had_attachments = bool(file_ids) # Per-channel ephemeral prompt from gateway.platforms.base import resolve_channel_prompt diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index cd8e237d3b5ff..d8a0d6da1bf18 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -6033,6 +6033,7 @@ async def _handle_slack_message_impl( self, event: dict, payload: Optional[dict] = None ) -> None: """Handle an incoming Slack message event.""" + is_message_edit = event.get("subtype") == "message_changed" # DEBUG entry log — fires BEFORE any filtering so users debugging # bot-to-bot interop, allow_bots config, or SLACK_ALLOWED_USERS # drops can confirm whether the event actually arrived from Slack @@ -7049,8 +7050,12 @@ async def _handle_slack_message_impl( # subtype=bot_message with user=None; flag them so the # gateway SLACK_ALLOW_BOTS bypass can authorize them # (they carry no user_id to match against the allowlist). - is_bot=bool(event.get("bot_id")) or event.get("subtype") == "bot_message", + is_bot=sender_is_bot, ) + # Transport-local privacy and replay signals. They are intentionally not + # serialized: an older relay cannot assert a private one-to-one surface. + source.is_one_to_one = is_one_to_one_dm + source.message_is_edit = is_message_edit # Per-channel ephemeral prompt from gateway.platforms.base import ( @@ -7110,6 +7115,7 @@ async def _handle_slack_message_impl( "slack_team_id": team_id, "slack_channel_id": channel_id, "slack_thread_ts": thread_ts, + "message_is_edit": is_message_edit, }, ) @@ -8545,6 +8551,8 @@ async def _handle_slash_command(self, command: dict) -> None: thread_id=thread_id, scope_id=team_id or None, ) + source.is_one_to_one = is_dm + source.message_is_edit = False event = MessageEvent( text=text, diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index f6dc8bc8b732c..a091ebd29c478 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -6743,9 +6743,10 @@ async def send_choice_picker( ) if not buttons: return SendResult(success=False, error="No choices") - # Two buttons per row keeps labels readable on mobile. + row_size = 1 if any(choice.get("full_width") for choice in choices) else 2 + # Settings stay compact; navigation choices can request a full row. keyboard = InlineKeyboardMarkup( - [buttons[i:i + 2] for i in range(0, len(buttons), 2)] + [buttons[i:i + row_size] for i in range(0, len(buttons), row_size)] ) thread_id = metadata.get("thread_id") if metadata else None @@ -6767,8 +6768,10 @@ async def send_choice_picker( ) self._choice_picker_state[str(chat_id)] = { + "expires_at": time.monotonic() + 120, "msg_id": msg.message_id, "choices": choices, + "requester_user_id": str((metadata or {}).get("requester_user_id") or ""), "session_key": session_key, "on_choice_selected": on_choice_selected, } @@ -6791,6 +6794,20 @@ async def _handle_choice_picker_callback( # picker message. query_message = getattr(query, "message", None) query_chat = getattr(query_message, "chat", None) + query_message_id = getattr(query_message, "message_id", None) + if query_message_id != state.get("msg_id"): + await query.answer(text="This menu has expired. Run the command again.") + return + if time.monotonic() > float(state.get("expires_at") or 0): + self._choice_picker_state.pop(chat_id, None) + await query.answer(text="This menu has expired. Run the command again.") + return + requester_user_id = str(state.get("requester_user_id") or "") + if requester_user_id and requester_user_id != str( + getattr(query.from_user, "id", "") + ): + await query.answer(text="⛔ This menu belongs to another user.") + return if not self._is_callback_user_authorized( str(getattr(query.from_user, "id", "")), chat_id=getattr(query_message, "chat_id", None), @@ -10886,6 +10903,10 @@ def _build_message_event( message_id=str(message.message_id), is_bot=bool(getattr(user, "is_bot", False)) if user else False, ) + source.is_one_to_one = ( + str(getattr(chat, "type", "") or "").casefold() == "private" + ) + source.message_is_edit = getattr(message, "edit_date", None) is not None # Extract reply context if this message is a reply. # Prefer Telegram's native partial quote (message.quote, TextQuote) diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 2749217a1b034..b2ac42d5c2e4c 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -1519,7 +1519,8 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv msg_type = MessageType.DOCUMENT # Determine chat type - is_group = data.get("isGroup", False) + raw_is_group = data.get("isGroup") + is_group = raw_is_group is True chat_type = "group" if is_group else "dm" # Build source @@ -1529,6 +1530,13 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv chat_type=chat_type, user_id=data.get("senderId"), user_name=data.get("senderName"), + is_bot=data.get("fromMe") is True and data.get("fromOwner") is not True, + ) + source.is_one_to_one = raw_is_group is False + source.message_is_edit = bool( + data.get("isEdited") is True + or str(data.get("nativeType") or "").casefold() + in {"editedmessage", "protocolmessage:message_edit"} ) # Download media URLs to the local cache so agent tools @@ -1662,6 +1670,8 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv metadata["whatsapp_native_type"] = native_type if isinstance(native_metadata, dict) and native_metadata: metadata["whatsapp_native"] = native_metadata + if source.message_is_edit: + metadata["message_is_edit"] = True # The bridge sets ``fromOwner: true`` on inbound fromMe messages # that look owner-typed (linked-device send, not echoed from our # own /send). Surfaced under a platform-prefixed key so plugins diff --git a/run_agent.py b/run_agent.py index 61329934f832b..c800e55137238 100644 --- a/run_agent.py +++ b/run_agent.py @@ -310,6 +310,16 @@ def _is_ephemeral_scaffolding(msg: Any) -> bool: _MAX_TOOL_WORKERS = 8 +# Wording of the periodic refresh emitted while a cross-process session turn +# lease is held elsewhere (``_on_session_turn_lease_wait``). The refresh only +# makes sense on surfaces that can update the initial wait notice in place; +# gateway/run.py derives its no-in-place-update suppression matcher from this +# constant (#89166) — never re-inline the wording at either site. +SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE = ( + "⏳ Still waiting for the other Hermes process on " + "this session ({elapsed_seconds}s)..." +) + # Intrinsic marker stamped on a message dict once it has been written to the # SQLite session store. Used by ``_flush_messages_to_session_db`` to decide # what is already durable. An object-identity (``id(msg)``) dedup set cannot be @@ -9168,8 +9178,9 @@ def _on_session_turn_lease_wait(elapsed: float) -> None: ) else: self._emit_status( - "⏳ Still waiting for the other Hermes process on " - f"this session ({int(elapsed)}s)..." + SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE.format( + elapsed_seconds=int(elapsed) + ) ) if not _turn_db.acquire_session_turn_lease( diff --git a/tests/gateway/platforms/test_api_server_room_controls.py b/tests/gateway/platforms/test_api_server_room_controls.py new file mode 100644 index 0000000000000..c660498ea8d66 --- /dev/null +++ b/tests/gateway/platforms/test_api_server_room_controls.py @@ -0,0 +1,248 @@ +"""Scoped reciprocal API for remote Group Chat control.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from gateway import hosted_room_controls, hosted_rooms +from gateway.config import PlatformConfig +from gateway.platforms import api_server_room_controls +from gateway.platforms.api_server import APIServerAdapter + + +HOME = "install:home" + + +class FakeService: + def __init__(self, db_path: Path) -> None: + self.db_path = db_path + self.retried: list[str] = [] + + def status(self, room_id: str): + pending = [] if self.retried else [{"kind": "retry", "task_id": "task-1"}] + return { + "working": False, + "blocked": bool(pending), + "counts": {"deferred": len(pending)}, + "pending_actions": pending, + } + + def send_server_owned(self, *, room_id, event_id, payload, actor): + room = hosted_rooms.room_state(self.db_path, room_id=room_id) + return hosted_rooms.append_event( + self.db_path, + room_id=room_id, + event_id=event_id, + kind="message.user", + actor=actor, + payload=payload, + authority_gateway_id=str(room["authority_gateway_id"]), + authority_epoch=int(room["authority_epoch"]), + ) + + def stop_room(self, room_id, *, cancel_id): + room = hosted_rooms.room_state(self.db_path, room_id=room_id) + hosted_rooms.request_room_stop( + self.db_path, + room_id=room_id, + cancel_id=cancel_id, + expected_gateway_id=str(room["authority_gateway_id"]), + expected_epoch=int(room["authority_epoch"]), + ) + return 1 + + def retry_room_task(self, room_id, *, task_id): + assert room_id == "room-1" + self.retried.append(task_id) + return {"status": "queued"} + + +@pytest.fixture +def control_api(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + db = home / "state.db" + hosted_rooms.create_room( + db, + room_id="room-1", + name="Release room", + members=[ + {"member_id": "member-peer", "profile": "reviewer", "handle": "reviewer"}, + {"member_id": "local", "profile": "local", "handle": "local"}, + ], + authority_gateway_id=HOME, + ) + issued = hosted_room_controls.issue_home_control_token( + db, + room_id="room-1", + member_id="member-peer", + authority_gateway_id=HOME, + authority_epoch=1, + expires_at=10_000_000_000, + ) + service = FakeService(db) + monkeypatch.setattr( + "tui_gateway.methods_groups.get_hosted_room_service", + lambda: service, + ) + adapter = APIServerAdapter( + PlatformConfig(enabled=True, extra={"key": "sk-secret"}) + ) + app = web.Application() + for method, path, handler in api_server_room_controls._http_routes(adapter): + app.router.add_route(method, path, handler) + headers = { + "Authorization": f"HermesRoomControl {issued.control_token}", + "X-Hermes-Room-Member": "member-peer", + } + return adapter, app, service, headers + + +def test_api_server_registers_reciprocal_control_routes(control_api): + adapter, _app, _service, _headers = control_api + routes = {(method, path) for method, path, _handler in adapter._http_route_table()} + assert ("GET", "/v1/room-controls/{room_id}") in routes + assert ("POST", "/v1/room-controls/{room_id}") in routes + assert ("DELETE", "/v1/room-controls/{room_id}") in routes + + +@pytest.mark.asyncio +async def test_read_send_stop_and_retry_are_scoped_and_replay_safe(control_api): + _adapter, app, service, headers = control_api + async with TestClient(TestServer(app)) as client: + denied = await client.get("/v1/room-controls/room-1") + assert denied.status == 401 + + initial = await client.get("/v1/room-controls/room-1", headers=headers) + assert initial.status == 200 + initial_payload = await initial.json() + assert initial_payload["room"]["name"] == "Release room" + assert all( + set(member) <= {"member_id", "handle", "display_name"} + for member in initial_payload["room"]["members"] + ) + + send_body = { + "action": "send", + "command_id": "remote-send-1", + "actor_display_name": "Signal", + "text": "Review the release", + } + sent = await client.post( + "/v1/room-controls/room-1", + json=send_body, + headers=headers, + ) + replayed = await client.post( + "/v1/room-controls/room-1", + json=send_body, + headers=headers, + ) + assert sent.status == replayed.status == 200 + events = hosted_rooms.read_events( + service.db_path, + room_id="room-1", + since_seq=0, + limit=20, + )["events"] + user_events = [event for event in events if event["kind"] == "message.user"] + assert len(user_events) == 1 + assert user_events[0]["actor"] == { + "kind": "user", + "id": "peer:member-peer", + "display_name": "Signal", + } + + retried = await client.post( + "/v1/room-controls/room-1", + json={"action": "retry", "command_id": "remote-retry-1"}, + headers=headers, + ) + retry_replay = await client.post( + "/v1/room-controls/room-1", + json={"action": "retry", "command_id": "remote-retry-1"}, + headers=headers, + ) + assert retried.status == retry_replay.status == 200 + assert service.retried == [] + assert len( + hosted_room_controls.load_pending_control_retries( + service.db_path, + room_id="room-1", + ) + ) == 1 + + stopped = await client.post( + "/v1/room-controls/room-1", + json={"action": "stop", "command_id": "remote-stop-1"}, + headers=headers, + ) + assert stopped.status == 200 + stopped_events = hosted_rooms.read_events( + service.db_path, + room_id="room-1", + since_seq=0, + limit=20, + )["events"] + assert any(event["kind"] == "room.stop_requested" for event in stopped_events) + + revoked = await client.delete( + "/v1/room-controls/room-1", + headers=headers, + ) + assert revoked.status == 200 + revoke_replay = await client.delete( + "/v1/room-controls/room-1", + headers=headers, + ) + assert revoke_replay.status == 200 + denied_after_revoke = await client.get( + "/v1/room-controls/room-1", + headers=headers, + ) + assert denied_after_revoke.status == 401 + + +@pytest.mark.asyncio +async def test_control_token_is_member_and_room_scoped(control_api): + _adapter, app, _service, headers = control_api + async with TestClient(TestServer(app)) as client: + wrong_member = await client.get( + "/v1/room-controls/room-1", + headers={**headers, "X-Hermes-Room-Member": "local"}, + ) + wrong_room = await client.get( + "/v1/room-controls/other-room", + headers=headers, + ) + assert wrong_member.status == 401 + assert wrong_room.status == 401 + + +@pytest.mark.asyncio +async def test_retry_is_queued_for_the_process_that_owns_the_room_lease( + control_api, +): + _adapter, app, service, headers = control_api + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/v1/room-controls/room-1", + json={"action": "retry", "command_id": "remote-retry-worker"}, + headers=headers, + ) + assert response.status == 200 + payload = await response.json() + assert payload["queued"] is True + assert payload["retried"] == 1 + pending = hosted_room_controls.load_pending_control_retries( + service.db_path, + room_id="room-1", + ) + assert [(item.command_id, item.task_ids) for item in pending] == [ + ("remote-retry-worker", ("task-1",)) + ] diff --git a/tests/gateway/test_api_server_room_grants.py b/tests/gateway/test_api_server_room_grants.py index 0b91945f2e2ca..841c36b648a55 100644 --- a/tests/gateway/test_api_server_room_grants.py +++ b/tests/gateway/test_api_server_room_grants.py @@ -209,3 +209,65 @@ async def test_capability_handler_uses_legacy_claims_monkeypatch(monkeypatch): request, permission="status", ) + + +@pytest.mark.asyncio +async def test_grant_revoke_cleans_reciprocal_control_on_the_target(monkeypatch): + from gateway import hosted_room_control_client, hosted_room_peer, hosted_rooms + + adapter = api_server.APIServerAdapter.__new__(api_server.APIServerAdapter) + adapter._read_json_body = AsyncMock(return_value=({}, None)) + adapter._room_grant_token = MagicMock(return_value="grant-token") + adapter._room_grant_secret = MagicMock(return_value=b"s" * 32) + claims = { + "room_id": "room-1", + "member_id": "member-peer", + "target_profile": "reviewer", + "target_install_id": "install-target", + "expires_at": 200, + "status_expires_at": 300, + } + monkeypatch.setattr(hosted_room_peer, "decode_room_grant", lambda *_a, **_k: claims) + monkeypatch.setattr( + hosted_rooms, "local_authority_gateway_id", lambda: "install-target" + ) + revoke_grant = MagicMock() + monkeypatch.setattr(hosted_rooms, "revoke_room_grant_scope", revoke_grant) + revoke_control = MagicMock(return_value=1) + monkeypatch.setattr( + hosted_room_control_client, + "revoke_stored_peer_control", + revoke_control, + ) + profile_token = api_server._api_request_profile.set("reviewer") + try: + response = await room_grants._handle_room_member_grant_revoke( + adapter, + object(), + _openai_error=api_server._openai_error, + _api_request_profile=api_server._api_request_profile, + ) + finally: + api_server._api_request_profile.reset(profile_token) + + assert response.status == 200 + revoke_grant.assert_called_once() + revoke_control.assert_called_once_with( + hosted_rooms.default_db_path(), + room_id="room-1", + member_id="member-peer", + ) + + revoke_control.side_effect = RuntimeError("home unreachable") + profile_token = api_server._api_request_profile.set("reviewer") + try: + retryable = await room_grants._handle_room_member_grant_revoke( + adapter, + object(), + _openai_error=api_server._openai_error, + _api_request_profile=api_server._api_request_profile, + ) + finally: + api_server._api_request_profile.reset(profile_token) + assert retryable.status == 503 + assert json.loads(retryable.text)["error"]["code"] == "room_control_cleanup_pending" diff --git a/tests/gateway/test_api_server_runs_extraction.py b/tests/gateway/test_api_server_runs_extraction.py index 2709321e19a31..b1052914c2640 100644 --- a/tests/gateway/test_api_server_runs_extraction.py +++ b/tests/gateway/test_api_server_runs_extraction.py @@ -169,6 +169,9 @@ def test_roomlink_and_run_route_tuples_are_shard_owned(): ("GET", "/v1/room-members/capabilities"), ("POST", "/v1/room-members/grants/refresh"), ("POST", "/v1/room-members/grants/revoke"), + ("GET", "/v1/room-controls/{room_id}"), + ("POST", "/v1/room-controls/{room_id}"), + ("DELETE", "/v1/room-controls/{room_id}"), ] assert [(method, path) for method, path, _ in run_routes] == [ ("POST", "/v1/runs"), @@ -178,5 +181,6 @@ def test_roomlink_and_run_route_tuples_are_shard_owned(): ("POST", "/v1/runs/{run_id}/steer"), ("POST", "/v1/runs/{run_id}/stop"), ] - assert all(handler.__self__ is adapter for _, _, handler in room_routes) + assert all(handler.__self__ is adapter for _, _, handler in room_routes[:4]) + assert all(callable(handler) for _, _, handler in room_routes[4:]) assert all(handler.__self__ is adapter for _, _, handler in run_routes) diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py index 20b91c25e1a71..308d8af441f65 100644 --- a/tests/gateway/test_command_bypass_active_session.py +++ b/tests/gateway/test_command_bypass_active_session.py @@ -316,6 +316,7 @@ class TestAllResolvableCommandsBypassGuard: ("/usage", "usage"), ("/reload-mcp", "reload-mcp"), ("/sethome", "sethome"), + ("/group 1 send hello", "group"), ], ) @pytest.mark.asyncio @@ -342,7 +343,7 @@ def test_should_bypass_returns_true_for_every_registered_command(self): for cmd in ( "model", "reasoning", "personality", "voice", "insights", "title", "resume", "retry", "undo", "compress", "usage", - "reload-mcp", "sethome", "reset", + "reload-mcp", "sethome", "group", "reset", ): assert should_bypass_active_session(cmd) is True, ( f"/{cmd} must bypass the active-session guard" diff --git a/tests/gateway/test_desktop_room_mailbox.py b/tests/gateway/test_desktop_room_mailbox.py new file mode 100644 index 0000000000000..aaa8cef71f40e --- /dev/null +++ b/tests/gateway/test_desktop_room_mailbox.py @@ -0,0 +1,823 @@ +from __future__ import annotations + +import hashlib +import sys + +import pytest + +from gateway import desktop_room_mailbox as mailbox + + +class Clock: + def __init__(self, value: float = 1000.0) -> None: + self.value = value + + def __call__(self) -> float: + return self.value + + +def authorities(*room_ids: str, token: str = "authority:one") -> list[dict]: + return [ + {"room_id": room_id, "authority_token": token} + for room_id in room_ids + ] + + +def authority_commitment(token: str = "authority:one") -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def test_enqueue_is_idempotent_and_rejects_key_reuse(tmp_path): + db = tmp_path / "state.db" + first = mailbox.enqueue_command( + db, + command_id="messaging:abc", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + replay = mailbox.enqueue_command( + db, + command_id="messaging:abc", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + + assert first["state"] == "pending" + assert replay["idempotent"] is True + with pytest.raises(mailbox.DesktopRoomMailboxError, match="different room work"): + mailbox.enqueue_command( + db, + command_id="messaging:abc", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "changed"}, + ) + + +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="POSIX mode bits are not enforced on Windows", +) +def test_enqueue_moves_the_cross_process_pending_signal(tmp_path): + db = tmp_path / "desktop_room_mailbox.db" + signal = mailbox.pending_signal_path(db) + + mailbox.enqueue_command( + db, + command_id="messaging:first", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + first = signal.read_text(encoding="ascii") + mailbox.enqueue_command( + db, + command_id="messaging:second", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "again"}, + ) + + assert signal.read_text(encoding="ascii") != first + assert signal.stat().st_mode & 0o777 == 0o600 + + +def test_signal_failure_does_not_change_a_durable_enqueue(tmp_path, monkeypatch): + db = tmp_path / "desktop_room_mailbox.db" + mailbox.enqueue_command( + db, + command_id="messaging:seed", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "seed"}, + ) + monkeypatch.setattr( + type(mailbox.pending_signal_path(db)), + "write_text", + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("read only")), + ) + + queued = mailbox.enqueue_command( + db, + command_id="messaging:still-durable", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + + assert queued["state"] == "pending" + + +def test_one_desktop_claims_and_presence_is_room_scoped(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:one", + room_id="name:Classic room", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("name:Classic room"), + clock=clock, + ) + duplicate = mailbox.claim_commands( + db, + consumer_id="desktop:second", + room_authorities=authorities("name:Classic room", token="authority:two"), + clock=clock, + ) + + assert [item["command_id"] for item in claimed] == ["messaging:one"] + assert duplicate == [] + assert mailbox.room_available(db, "name:Classic room", clock=clock) is True + assert mailbox.room_available(db, "another-room", clock=clock) is False + + +def test_expired_claim_is_recovered_by_the_registered_desktop(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:retry", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + first = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + claim_ttl=10, + presence_ttl=10, + clock=clock, + ) + clock.value += 11 + second = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + + assert first[0]["attempts"] == 1 + assert second[0]["attempts"] == 2 + with pytest.raises(mailbox.DesktopRoomMailboxError, match="no longer owned"): + mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id="messaging:retry", + lease_token=first[0]["lease_token"], + success=True, + result={"thread_id": "old"}, + clock=clock, + ) + + +def test_completion_ack_retry_is_idempotent(tmp_path): + db = tmp_path / "state.db" + mailbox.enqueue_command( + db, + command_id="messaging:complete", + room_id="room-1", + authority_hash=authority_commitment(), + action="stop", + payload={"target_command_id": "messaging:send-1"}, + ) + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + ) + first = mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id="messaging:complete", + lease_token=claimed[0]["lease_token"], + success=True, + result={"stopped": True}, + ) + replay = mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id="messaging:complete", + lease_token=claimed[0]["lease_token"], + success=True, + result={"stopped": True}, + ) + + assert first["state"] == "completed" + assert replay["idempotent"] is True + + +def test_explicit_retry_requeues_one_failed_command_idempotently(tmp_path): + db = tmp_path / "state.db" + mailbox.enqueue_command( + db, + command_id="messaging:failed", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + )[0] + failed = mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id=claimed["command_id"], + lease_token=claimed["lease_token"], + success=False, + result={"error": "temporarily unavailable"}, + ) + + assert failed["state"] == "failed" + retried = mailbox.retry_failed_command(db, room_id="room-1") + replay = mailbox.retry_failed_command( + db, + room_id="room-1", + command_id=claimed["command_id"], + ) + assert retried["state"] == "pending" + assert replay["idempotent"] is True + reclaimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + ) + assert reclaimed[0]["command_id"] == "messaging:failed" + + +def test_reclaim_rotates_token_and_fences_a_stale_attempt(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:fenced", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + first = mailbox.claim_commands( + db, + consumer_id="desktop:same-install", + room_authorities=authorities("room-1"), + claim_ttl=5, + clock=clock, + )[0] + clock.value += 6 + second = mailbox.claim_commands( + db, + consumer_id="desktop:same-install", + room_authorities=authorities("room-1"), + claim_ttl=5, + clock=clock, + )[0] + + assert first["lease_token"] != second["lease_token"] + with pytest.raises(mailbox.DesktopRoomMailboxError, match="no longer owned"): + mailbox.complete_command( + db, + consumer_id="desktop:same-install", + command_id="messaging:fenced", + lease_token=first["lease_token"], + success=True, + result={"thread_id": "old"}, + clock=clock, + ) + + +def test_live_claim_can_be_renewed(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:renew", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + claim_ttl=10, + clock=clock, + )[0] + clock.value += 8 + renewed = mailbox.renew_command( + db, + consumer_id="desktop:first", + command_id="messaging:renew", + lease_token=claimed["lease_token"], + claim_ttl=10, + clock=clock, + ) + clock.value += 5 + + assert renewed["lease_token"] == claimed["lease_token"] + assert mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id="messaging:renew", + lease_token=claimed["lease_token"], + success=True, + result={"thread_id": "thread-1"}, + clock=clock, + )["state"] == "completed" + + +def test_presence_expires_without_deleting_pending_work(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:later", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "later"}, + clock=clock, + ) + mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + presence_ttl=5, + claim_ttl=5, + clock=clock, + ) + clock.value += 6 + + assert mailbox.room_available(db, "room-1", clock=clock) is False + reclaimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + assert reclaimed[0]["command_id"] == "messaging:later" + + +def test_authority_token_fences_a_cold_desktop_after_owner_expiry(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:fenced-room", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + claim_ttl=5, + presence_ttl=5, + clock=clock, + ) + clock.value += 6 + + assert mailbox.claim_commands( + db, + consumer_id="desktop:cold", + room_authorities=authorities("room-1", token="authority:wrong"), + clock=clock, + ) == [] + reclaimed = mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + clock=clock, + ) + assert reclaimed[0]["command_id"] == "messaging:fenced-room" + + +def test_claim_cannot_establish_room_authority(tmp_path): + db = tmp_path / "state.db" + mailbox.enqueue_command( + db, + command_id="messaging:unregistered", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + + assert mailbox.claim_commands( + db, + consumer_id="desktop:untrusted", + room_authorities=authorities("room-1", token="authority:guessed"), + ) == [] + + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + ) + assert [item["command_id"] for item in claimed] == ["messaging:unregistered"] + + +def test_projected_authority_commitment_is_idempotent_and_fenced(tmp_path): + db = tmp_path / "state.db" + assert mailbox.register_projected_authorities( + db, + [{"room_id": "room-1", "authority_hash": authority_commitment()}], + ) == ["room-1"] + assert mailbox.register_projected_authorities( + db, + [{"room_id": "room-1", "authority_hash": authority_commitment()}], + ) == ["room-1"] + + assert mailbox.register_projected_authorities( + db, + [{ + "room_id": "room-1", + "authority_hash": authority_commitment("authority:other"), + }], + ) == [] + + assert mailbox.register_projected_authorities( + db, + [ + { + "room_id": "room-1", + "authority_hash": authority_commitment("authority:other"), + }, + {"room_id": "room-2", "authority_hash": authority_commitment()}, + ], + ) == ["room-2"] + + with pytest.raises(mailbox.DesktopRoomMailboxError, match="commitment"): + mailbox.enqueue_command( + db, + command_id="messaging:conflict", + room_id="room-1", + authority_hash=authority_commitment("authority:other"), + action="send", + payload={"message": "must not replace owner"}, + ) + + +def test_stop_supersedes_an_earlier_send_before_it_is_claimed(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + for action in ("send", "stop"): + mailbox.enqueue_command( + db, + command_id=f"messaging:{action}", + room_id="room-1", + authority_hash=authority_commitment(), + action=action, + payload=( + {"message": "hello"} + if action == "send" + else {"target_command_id": "messaging:send"} + ), + clock=clock, + ) + + stopped = mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + actions=["stop"], + clock=clock, + ) + assert [item["action"] for item in stopped] == ["stop"] + assert stopped[0]["target_command_state"] == "failed" + assert stopped[0]["target_result_code"] == "superseded_by_stop" + sends = mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + actions=["send"], + clock=clock, + ) + assert sends == [] + + +def test_renew_keeps_room_ownership_alive_for_long_turn(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:long", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + command = mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + claim_ttl=45, + presence_ttl=90, + clock=clock, + )[0] + + for _ in range(8): + clock.value += 30 + mailbox.renew_command( + db, + consumer_id="desktop:owner", + command_id=command["command_id"], + lease_token=command["lease_token"], + claim_ttl=45, + presence_ttl=90, + clock=clock, + ) + + assert mailbox.room_available(db, "room-1", clock=clock) is True + assert mailbox.claim_commands( + db, + consumer_id="desktop:other", + room_authorities=authorities("room-1"), + actions=["stop"], + clock=clock, + ) == [] + + +def test_default_presence_overlaps_the_minute_desktop_backstop(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.register_projected_authorities( + db, + [{"room_id": "room-1", "authority_hash": authority_commitment()}], + clock=clock, + ) + mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + + clock.value += 61 + assert mailbox.room_available(db, "room-1", clock=clock) is True + clock.value += 30 + assert mailbox.room_available(db, "room-1", clock=clock) is False + + +def test_latest_command_state_is_scoped_per_room(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:first", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "first"}, + clock=clock, + ) + clock.value += 1 + mailbox.enqueue_command( + db, + command_id="messaging:second", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "second"}, + clock=clock, + ) + mailbox.enqueue_command( + db, + command_id="messaging:other", + room_id="room-2", + authority_hash=authority_commitment(), + action="stop", + payload={"target_command_id": "messaging:send-1"}, + clock=clock, + ) + + states = mailbox.latest_command_states(db, ["room-1", "room-2"]) + + assert states["room-1"]["command_id"] == "messaging:second" + assert states["room-2"]["command_id"] == "messaging:other" + + +def test_paged_claims_preserve_presence_for_every_owned_room(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + rooms = [f"room-{index}" for index in range(260)] + + for index in range(0, len(rooms), mailbox.MAX_ROOM_IDS): + batch = rooms[index : index + mailbox.MAX_ROOM_IDS] + mailbox.register_projected_authorities( + db, + [ + {"room_id": room_id, "authority_hash": authority_commitment()} + for room_id in batch + ], + clock=clock, + ) + mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities(*batch), + clock=clock, + ) + + assert mailbox.available_room_ids(db, rooms, clock=clock) == set(rooms) + + +def test_presence_refresh_allows_secret_proven_takeover_after_expiry(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.register_projected_authorities( + db, + [{"room_id": "room-1", "authority_hash": authority_commitment()}], + clock=clock, + ) + + assert mailbox.refresh_presence( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + presence_ttl=5, + clock=clock, + ) == ["room-1"] + assert mailbox.refresh_presence( + db, + consumer_id="desktop:second", + room_authorities=authorities("room-1"), + presence_ttl=5, + clock=clock, + ) == [] + + clock.value += 6 + assert mailbox.refresh_presence( + db, + consumer_id="desktop:second", + room_authorities=authorities("room-1", token="authority:wrong"), + clock=clock, + ) == [] + assert mailbox.refresh_presence( + db, + consumer_id="desktop:second", + room_authorities=authorities("room-1"), + clock=clock, + ) == ["room-1"] + + +def test_pending_command_expires_instead_of_running_days_later(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:stale", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "old"}, + clock=clock, + ) + + clock.value += mailbox.PENDING_TTL_SECONDS + 1 + assert mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) == [] + state = mailbox.latest_command_states(db, ["room-1"])["room-1"] + assert state["state"] == "failed" + assert state["result"]["code"] == "command_expired" + + +def test_retry_requeues_all_bounded_expired_commands_oldest_first(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + for index in range(2): + mailbox.enqueue_command( + db, + command_id=f"messaging:stale-{index}", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": str(index)}, + clock=clock, + ) + clock.value += 1 + + clock.value += mailbox.PENDING_TTL_SECONDS + 1 + assert mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) == [] + + frozen = mailbox.retryable_command_ids(db, room_id="room-1") + assert frozen == ("messaging:stale-0", "messaging:stale-1") + retried = mailbox.retry_failed_commands( + db, + room_id="room-1", + command_ids=frozen, + clock=clock, + ) + assert [command["command_id"] for command in retried] == list(frozen) + + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + assert [command["payload"]["message"] for command in claimed] == ["0", "1"] + + +def test_pending_queue_is_bounded_per_group_chat(tmp_path, monkeypatch): + db = tmp_path / "state.db" + monkeypatch.setattr(mailbox, "MAX_PENDING_COMMANDS_PER_ROOM", 2) + for index in range(2): + mailbox.enqueue_command( + db, + command_id=f"messaging:{index}", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": str(index)}, + ) + + with pytest.raises(mailbox.DesktopRoomMailboxError, match="too many commands"): + mailbox.enqueue_command( + db, + command_id="messaging:overflow", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "overflow"}, + ) + + +def test_stop_supersedes_pending_sends_and_is_claimed_first(tmp_path): + db = tmp_path / "state.db" + for index in range(2): + mailbox.enqueue_command( + db, + command_id=f"messaging:send-{index}", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": str(index)}, + ) + mailbox.enqueue_command( + db, + command_id="messaging:stop", + room_id="room-1", + authority_hash=authority_commitment(), + action="stop", + payload={"target_command_id": "messaging:send-1"}, + ) + + with mailbox._transaction(db) as conn: + send_states = { + str(row["command_id"]): str(row["state"]) + for row in conn.execute( + "SELECT command_id, state FROM desktop_room_commands WHERE action='send'" + ) + } + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + ) + + assert send_states == { + "messaging:send-0": "failed", + "messaging:send-1": "failed", + } + assert [command["command_id"] for command in claimed] == ["messaging:stop"] + assert claimed[0]["target_command_state"] == "failed" + assert claimed[0]["target_result_code"] == "superseded_by_stop" diff --git a/tests/gateway/test_discord_component_auth.py b/tests/gateway/test_discord_component_auth.py index fd6838a941625..2d647bfdba529 100644 --- a/tests/gateway/test_discord_component_auth.py +++ b/tests/gateway/test_discord_component_auth.py @@ -13,6 +13,7 @@ """ from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -20,6 +21,8 @@ # importing the production module. from plugins.platforms.discord.adapter import ( # noqa: E402 ClarifyChoiceView, + ChoicePickerView, + DiscordAdapter, ExecApprovalView, ModelPickerView, SlashConfirmView, @@ -145,6 +148,68 @@ def test_clarify_choice_view_accepts_role_allowlist(): assert view._check_auth(_interaction(99999, role_ids=[7])) is False +def test_choice_picker_is_bound_to_the_exact_requester(): + async def _noop(*_args): + return "" + + view = ChoicePickerView( + choices=[{"value": "1", "label": "Room"}], + on_choice_selected=_noop, + allowed_user_ids={"11111", "22222"}, + requester_user_id="11111", + ) + + assert view._check_auth(_interaction(11111)) is True + assert view._check_auth(_interaction(22222)) is False + + +@pytest.mark.asyncio +async def test_navigation_picker_uses_neutral_discord_chrome(monkeypatch): + from plugins.platforms.discord import adapter as discord_adapter + + class _Embed: + def __init__(self, *, title=None, description=None, color=None): + self.title = title + self.description = description + self.color = color + + monkeypatch.setattr(discord_adapter, "DISCORD_AVAILABLE", True) + monkeypatch.setattr(discord_adapter.discord, "Embed", _Embed) + monkeypatch.setattr(discord_adapter.discord.Color, "blue", lambda: "blue") + channel = SimpleNamespace( + send=AsyncMock(return_value=SimpleNamespace(id=7)), + ) + client = SimpleNamespace( + get_channel=lambda _channel_id: channel, + fetch_channel=AsyncMock(return_value=channel), + ) + adapter = object.__new__(DiscordAdapter) + adapter._client = client + adapter._allowed_user_ids = {"11111"} + adapter._allowed_role_ids = set() + + result = await adapter.send_choice_picker( + chat_id="123", + title="👥 Group Chats\nChoose a recent Group Chat.", + choices=[ + { + "value": "room-token", + "label": "🟢 Release room", + "full_width": True, + } + ], + session_key="session-1", + on_choice_selected=AsyncMock(), + metadata={"requester_user_id": "11111"}, + ) + + assert result.success is True + sent = channel.send.await_args.kwargs + assert sent["embed"].title == "👥 Group Chats" + assert sent["view"].navigation is True + assert sent["view"].requester_user_id == "11111" + + # --------------------------------------------------------------------------- # Empty allowlists across views: fail closed unless allow-all is explicit. # --------------------------------------------------------------------------- @@ -277,4 +342,3 @@ def test_other_views_not_admin_gated(): session_key="s", confirm_id="c", allowed_user_ids={"11111"} ) assert sc._check_auth(_interaction(11111)) is True - diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index e3e5a39ff57ce..eca856ea3c97a 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -474,6 +474,13 @@ async def _empty(): return _empty() +class _FakeDMChannel(_discord_mod.DMChannel): + def __init__(self, channel_id=101): + self.id = channel_id + self.name = "Direct message" + self.topic = None + + class _FakeThreadChannel(_discord_mod.Thread): """isinstance(ch, discord.Thread) → True.""" @@ -493,6 +500,38 @@ async def _empty(): return _empty() +def test_discord_slash_interaction_supplies_room_control_idempotency(adapter): + from gateway.hosted_room_messaging import messaging_event_id + + channel = _FakeTextChannel(channel_id=123) + interaction = SimpleNamespace( + id=987654321, + channel=channel, + channel_id=channel.id, + user=SimpleNamespace(id=42, display_name="Jezza"), + ) + event = adapter._build_slash_event( + interaction, + "/group 1 send hello", + ) + assert messaging_event_id(event) == messaging_event_id(event) + assert event.source.is_one_to_one is False + + +def test_discord_dm_slash_marks_verified_one_to_one(adapter): + channel = _FakeDMChannel() + interaction = SimpleNamespace( + id=987654322, + channel=channel, + channel_id=channel.id, + user=SimpleNamespace(id=42, display_name="Jezza"), + ) + + event = adapter._build_slash_event(interaction, "/group") + + assert event.source.is_one_to_one is True + + def _fake_message(channel, *, content="Hello", author_id=42, display_name="Jezza"): return SimpleNamespace( author=SimpleNamespace(id=author_id, display_name=display_name, bot=False), @@ -600,5 +639,3 @@ def test_register_skill_command_payload_fits_discord_8kb_limit(adapter): f"Flat /skill command payload is ~{len(payload)} bytes — the whole " f"point of this design is that it stays small regardless of skill count" ) - - diff --git a/tests/gateway/test_group_chat_matrix_adapter.py b/tests/gateway/test_group_chat_matrix_adapter.py new file mode 100644 index 0000000000000..e9d0b49edc96c --- /dev/null +++ b/tests/gateway/test_group_chat_matrix_adapter.py @@ -0,0 +1,84 @@ +"""Matrix ingress guarantees used by Group Chat messaging controls.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.hosted_room_messaging import messaging_event_id +from tests.gateway.test_matrix import _make_adapter + + +@pytest.mark.asyncio +async def test_named_two_member_dm_stamps_one_to_one_proof(): + adapter = _make_adapter() + adapter._joined_rooms = {"!named_dm:ex.org"} + adapter._dm_rooms = {"!named_dm:ex.org": True} + adapter._client = MagicMock() + adapter._client.get_state_event = AsyncMock( + side_effect=lambda _room_id, event_type: {"name": "Alice & Bot"} + if event_type == "m.room.name" + else (_ for _ in ()).throw(Exception("no alias")) + ) + adapter._client.state_store = MagicMock() + adapter._client.state_store.get_members = AsyncMock( + return_value=["@bot:ex.org", "@alice:ex.org"] + ) + adapter._get_display_name = AsyncMock(return_value="Alice") + adapter._background_read_receipt = MagicMock() + + identity = await adapter._resolve_room_identity("!named_dm:ex.org") + context = await adapter._resolve_message_context( + "!named_dm:ex.org", + "@alice:ex.org", + "$event", + "hello", + {"body": "hello"}, + {}, + ) + + assert identity.chat_type == "dm" + assert identity.joined_member_count == 2 + assert context is not None + assert context[-1].is_one_to_one is True + + +@pytest.mark.asyncio +async def test_room_control_normalizes_and_keeps_matrix_event_id(): + adapter = _make_adapter() + adapter._is_dm_room = AsyncMock(return_value=True) + adapter._require_mention = True + adapter._free_rooms = set() + captured = [] + + async def capture(event): + captured.append(event) + + adapter.handle_message = capture + await adapter._handle_text_message( + room_id="!room:example.org", + sender="@alice:example.org", + event_id="$matrix-command-test", + event_ts=0.0, + source_content={"msgtype": "m.text", "body": "!group 1 send hello"}, + relates_to={}, + ) + + assert len(captured) == 1 + assert captured[0].text == "/group 1 send hello" + assert messaging_event_id(captured[0]) == messaging_event_id(captured[0]) + + +def test_picker_literal_at_signs_do_not_emit_matrix_mentions(): + adapter = _make_adapter() + adapter._allow_room_mentions = True + + content = adapter._build_text_message_content( + "🟢 1. @room\n" + "🤖 Operator · alice:example.org\n" + "💬 **@alice:example.org**\n" + "• **Operator (`@alice:example.org`):** @room status" + ) + + assert "m.mentions" not in content diff --git a/tests/gateway/test_group_chat_slack_adapter.py b/tests/gateway/test_group_chat_slack_adapter.py new file mode 100644 index 0000000000000..d7cf3b513189b --- /dev/null +++ b/tests/gateway/test_group_chat_slack_adapter.py @@ -0,0 +1,112 @@ +"""Slack ingress guarantees used by Group Chat messaging controls.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from gateway.hosted_room_messaging import messaging_event_id +from tests.gateway.test_slack import _redirect_cache, adapter + + +@pytest.mark.asyncio +async def test_users_info_bot_classification_reaches_session_source(adapter): + adapter.config.extra["allow_bots"] = "all" + adapter._app.client.users_info = AsyncMock( + return_value={ + "user": { + "is_bot": True, + "profile": {"display_name": "Peer Bot"}, + "real_name": "Peer Bot", + } + } + ) + event = { + "type": "message", + "user": "U_PEER_BOT", + "channel": "D_SHARED", + "channel_type": "im", + "text": "hello from a peer bot", + "ts": "1770000000.000001", + } + + await adapter._handle_slack_message(event) + + delivered = adapter.handle_message.await_args.args[0] + assert delivered.source.is_bot is True + assert delivered.source.is_one_to_one is True + + +@pytest.mark.asyncio +async def test_channel_slash_command_uses_group_session_semantics(adapter): + await adapter._handle_slash_command( + { + "text": "hello", + "user_id": "U123", + "channel_id": "C123", + "team_id": "T123", + } + ) + + event = adapter.handle_message.await_args.args[0] + assert event.source.chat_type == "group" + assert event.source.chat_id == "C123" + assert event.source.user_id == "U123" + assert event.source.scope_id == "T123" + assert event.source.is_one_to_one is False + + +@pytest.mark.asyncio +async def test_message_edit_stamps_untrusted_command_provenance(adapter): + await adapter._handle_slack_message( + { + "text": "whats the rapchat summary for last 12 hours", + "user": "U_USER", + "channel": "C123", + "channel_type": "mpim", + "team": "T123", + "ts": "1234567890.000001", + } + ) + adapter.handle_message.assert_not_called() + + await adapter._handle_slack_message( + { + "subtype": "message_changed", + "channel": "C123", + "channel_type": "mpim", + "team": "T123", + "ts": "1234567890.000001", + "message": { + "text": "<@U_BOT> whats the rapchat summary for last 12 hours", + "user": "U_USER", + "channel": "C123", + "ts": "1234567890.000001", + "edited": {"user": "U_USER", "ts": "1234567899.000001"}, + }, + } + ) + + event = adapter.handle_message.call_args[0][0] + assert event.source.is_one_to_one is False + assert event.source.message_is_edit is True + assert event.metadata["message_is_edit"] is True + + +@pytest.mark.asyncio +async def test_room_control_keeps_slack_trigger_for_idempotency(adapter): + await adapter._handle_slash_command( + { + "command": "/hermes", + "text": "group 1 send hello", + "trigger_id": "trigger-room-1", + "user_id": "U1", + "channel_id": "C1", + "team_id": "T1", + } + ) + + event = adapter.handle_message.call_args[0][0] + assert event.text == "/group 1 send hello" + assert messaging_event_id(event) == messaging_event_id(event) diff --git a/tests/gateway/test_hosted_room_control_client.py b/tests/gateway/test_hosted_room_control_client.py new file mode 100644 index 0000000000000..b3dc823260702 --- /dev/null +++ b/tests/gateway/test_hosted_room_control_client.py @@ -0,0 +1,161 @@ +"""Credential-safe reciprocal Group Chat control client.""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from gateway import hosted_room_controls +from gateway.hosted_room_control_client import ( + RoomControlHTTPClient, + revoke_stored_peer_control, +) +from gateway.hosted_room_controls import StoredPeerRoomControl + + +class ControlHandler(BaseHTTPRequestHandler): + requests = [] + + def _reply(self, payload): + data = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + type(self).requests.append( + ("GET", self.path, dict(self.headers), None) + ) + self._reply({"room": {"room_id": "room-1"}, "events": []}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length) or b"{}") + type(self).requests.append( + ("POST", self.path, dict(self.headers), body) + ) + self._reply({"action": body["action"], "summary": {"events": []}}) + + def do_DELETE(self): + type(self).requests.append( + ("DELETE", self.path, dict(self.headers), None) + ) + self._reply({"revoked": 1}) + + def log_message(self, *_args): + pass + + +@pytest.fixture +def control_server(): + ControlHandler.requests = [] + server = HTTPServer(("127.0.0.1", 0), ControlHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join(timeout=5) + + +def _link(url: str) -> StoredPeerRoomControl: + return StoredPeerRoomControl( + room_id="room-1", + member_id="member-peer", + home_url=url, + transport_security="loopback", + authority_gateway_id="install:home", + authority_epoch=1, + room_name="Planning", + member_count=2, + control_token="A" * 43, + status="active", + created_at=1, + updated_at=1, + expires_at=10_000_000_000, + ) + + +def test_summary_and_mutation_keep_the_token_in_headers(control_server): + client = RoomControlHTTPClient(_link(control_server)) + + assert client.summary()["room"]["room_id"] == "room-1" + assert client.mutate( + action="send", + command_id="command-1", + text="hello", + actor_display_name="Signal", + )["action"] == "send" + client.revoke() + + assert [request[0] for request in ControlHandler.requests] == [ + "GET", + "POST", + "DELETE", + ] + for _method, path, headers, body in ControlHandler.requests: + assert path == "/v1/room-controls/room-1" + assert headers["Authorization"] == "HermesRoomControl " + "A" * 43 + assert headers["X-Hermes-Room-Member"] == "member-peer" + assert "A" * 43 not in repr(body) + + +def test_control_client_refuses_cross_origin_redirects(): + class RedirectHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(302) + self.send_header("Location", "http://127.0.0.1:9/stolen") + self.end_headers() + + def log_message(self, *_args): + pass + + server = HTTPServer(("127.0.0.1", 0), RedirectHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = RoomControlHTTPClient(_link(f"http://127.0.0.1:{server.server_port}")) + with pytest.raises(Exception): + client.summary() + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_stored_peer_revoke_contacts_home_before_erasing_bearer( + tmp_path, monkeypatch +): + db = tmp_path / "state.db" + saved = hosted_room_controls.save_peer_control_link( + db, + room_id="room-1", + member_id="member-peer", + room_name="Planning", + member_count=2, + home_url="https://home.example.test", + authority_gateway_id="install:home", + authority_epoch=1, + control_token="A" * 43, + expires_at=10_000_000_000, + now=20, + ) + revoked = [] + monkeypatch.setattr( + RoomControlHTTPClient, + "revoke", + lambda self: revoked.append(self.link.room_id), + ) + + assert revoke_stored_peer_control( + db, room_id="room-1", member_id="member-peer" + ) == 1 + assert revoked == [saved.link.room_id] + assert hosted_room_controls.load_peer_control_links( + db, include_inactive=True, now=30 + ).links == () diff --git a/tests/gateway/test_hosted_room_controls.py b/tests/gateway/test_hosted_room_controls.py new file mode 100644 index 0000000000000..3c40bc808375a --- /dev/null +++ b/tests/gateway/test_hosted_room_controls.py @@ -0,0 +1,637 @@ +"""Storage and credential tests for reciprocal hosted-room control.""" + +from __future__ import annotations + +import hmac +import json +import os +import secrets +import sqlite3 +import stat +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from gateway import hosted_room_controls as controls +from gateway import hosted_rooms + + +HOME = "install:gateway-a" + + +def _create_room(db, room_id="room-1", *, authority=HOME, epoch=1): + room = hosted_rooms.create_room( + db, + room_id=room_id, + name="Release room", + members=[ + { + "member_id": "member-1", + "profile": "reviewer", + "handle": "reviewer", + } + ], + authority_gateway_id=authority, + now=10, + ) + assert room["authority_epoch"] == epoch + return room + + +def _issue(db, *, room_id="room-1", member_id="member-1", now=20): + return controls.issue_home_control_token( + db, + room_id=room_id, + member_id=member_id, + authority_gateway_id=HOME, + authority_epoch=1, + expires_at=now + 600, + now=now, + ) + + +def _save(db, token, *, room_id="room-1", member_id="member-1", **overrides): + values = { + "home_url": "https://home.example.test", + "authority_gateway_id": HOME, + "authority_epoch": 1, + "room_name": "Planning", + "member_count": 2, + "expires_at": 620, + "now": 20, + **overrides, + } + return controls.save_peer_control_link( + db, + room_id=room_id, + member_id=member_id, + control_token=token, + **values, + ) + + +def test_home_stores_only_sha256_and_never_exposes_token(tmp_path): + db = tmp_path / "state.db" + _create_room(db) + issued = _issue(db) + + with sqlite3.connect(db) as conn: + row = conn.execute( + "SELECT token_hash, status FROM hosted_room_control_tokens" + ).fetchone() + assert isinstance(row[0], bytes) + assert len(row[0]) == 32 + assert row[0] != issued.control_token.encode() + assert row[1] == "active" + assert issued.control_token not in repr(issued) + assert issued.control_token not in repr(issued.as_status()) + assert issued.control_token.encode() not in db.read_bytes() + + +def test_home_invitation_replay_recovers_the_same_opaque_token( + tmp_path, monkeypatch +): + db = tmp_path / "state.db" + _create_room(db) + monkeypatch.setattr( + controls, + "gateway_room_grant_secret", + lambda: b"s" * 32, + ) + common = { + "room_id": "room-1", + "member_id": "member-1", + "authority_gateway_id": HOME, + "authority_epoch": 1, + "expires_at": controls.ROOM_LIFETIME_EXPIRES_AT, + "request_id": "desktop-room-1-member-1-v1", + } + + first = controls.issue_home_control_token(db, now=20, **common) + replay = controls.issue_home_control_token(db, now=30, **common) + + assert replay.control_token == first.control_token + with sqlite3.connect(db) as conn: + assert conn.execute( + "SELECT COUNT(*) FROM hosted_room_control_tokens" + ).fetchone()[0] == 1 + with pytest.raises(controls.HostedRoomControlConflictError): + controls.issue_home_control_token( + db, + now=40, + **{**common, "request_id": "different-request"}, + ) + + +def test_previous_control_schema_adds_request_id_before_use(tmp_path, monkeypatch): + db = tmp_path / "state.db" + _create_room(db) + with sqlite3.connect(db) as conn: + conn.execute( + """CREATE TABLE hosted_room_control_tokens ( + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + token_hash BLOB NOT NULL, + status TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + expires_at REAL NOT NULL, + revoked_at REAL, + PRIMARY KEY ( + room_id, member_id, authority_gateway_id, authority_epoch + ) + )""" + ) + monkeypatch.setattr( + controls, + "gateway_room_grant_secret", + lambda: b"s" * 32, + ) + + controls.issue_home_control_token( + db, + room_id="room-1", + member_id="member-1", + authority_gateway_id=HOME, + authority_epoch=1, + expires_at=controls.ROOM_LIFETIME_EXPIRES_AT, + request_id="desktop-room-1-member-1-v1", + now=20, + ) + + with sqlite3.connect(db) as conn: + columns = { + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_control_tokens)") + } + request_id = conn.execute( + "SELECT request_id FROM hosted_room_control_tokens" + ).fetchone()[0] + assert "request_id" in columns + assert request_id == "desktop-room-1-member-1-v1" + + +def test_verify_is_exactly_room_member_authority_epoch_and_active_room_scoped(tmp_path): + db = tmp_path / "state.db" + _create_room(db) + _create_room(db, "room-2") + issued = _issue(db) + base = { + "room_id": "room-1", + "member_id": "member-1", + "authority_gateway_id": HOME, + "authority_epoch": 1, + "control_token": issued.control_token, + "now": 30, + } + + assert controls.verify_home_control_token(db, **base) + for changed in ( + {"room_id": "room-2"}, + {"member_id": "member-2"}, + {"authority_gateway_id": "install:gateway-b"}, + {"authority_epoch": 2}, + {"control_token": "A" * 43}, + ): + assert not controls.verify_home_control_token(db, **{**base, **changed}) + + hosted_rooms.disband_room( + db, + room_id="room-1", + expected_gateway_id=HOME, + expected_epoch=1, + now=40, + ) + assert not controls.verify_home_control_token(db, **{**base, "now": 50}) + + +def test_verify_always_uses_constant_time_digest_comparison(tmp_path, monkeypatch): + db = tmp_path / "state.db" + _create_room(db) + issued = _issue(db) + seen = [] + original = hmac.compare_digest + + def record(left, right): + seen.append((left, right)) + return original(left, right) + + monkeypatch.setattr(controls.hmac, "compare_digest", record) + assert ( + controls.verify_home_control_token( + db, + room_id="room-1", + member_id="missing-member", + authority_gateway_id=HOME, + authority_epoch=1, + control_token=issued.control_token, + now=30, + ) + is False + ) + assert len(seen) == 1 + assert all(isinstance(value, bytes) and len(value) == 32 for value in seen[0]) + + +def test_home_expiry_and_revocation_fail_closed_and_revoke_is_idempotent(tmp_path): + db = tmp_path / "state.db" + _create_room(db) + issued = _issue(db) + kwargs = { + "room_id": "room-1", + "member_id": "member-1", + "authority_gateway_id": HOME, + "authority_epoch": 1, + "control_token": issued.control_token, + } + + assert not controls.verify_home_control_token(db, **kwargs, now=620) + assert ( + controls.revoke_home_control_tokens( + db, room_id="room-1", member_id="member-1", now=100 + ) + == 1 + ) + assert ( + controls.revoke_home_control_tokens( + db, room_id="room-1", member_id="member-1", now=101 + ) + == 0 + ) + assert not controls.verify_home_control_token(db, **kwargs, now=110) + + +def test_malformed_home_row_fails_closed_without_skipping_digest_comparison( + tmp_path, monkeypatch +): + db = tmp_path / "state.db" + _create_room(db) + issued = _issue(db) + with sqlite3.connect(db) as conn: + conn.execute("UPDATE hosted_room_control_tokens SET expires_at='not-a-time'") + conn.commit() + compared = 0 + original = hmac.compare_digest + + def record(left, right): + nonlocal compared + compared += 1 + return original(left, right) + + monkeypatch.setattr(controls.hmac, "compare_digest", record) + assert not controls.verify_home_control_token( + db, + room_id="room-1", + member_id="member-1", + authority_gateway_id=HOME, + authority_epoch=1, + control_token=issued.control_token, + now=30, + ) + assert compared == 1 + + +def test_revoked_or_expired_scope_can_issue_a_new_opaque_token(tmp_path): + db = tmp_path / "state.db" + _create_room(db) + first = _issue(db) + controls.revoke_home_control_tokens(db, room_id="room-1", now=30) + second = _issue(db, now=40) + + assert first.control_token != second.control_token + common = { + "room_id": "room-1", + "member_id": "member-1", + "authority_gateway_id": HOME, + "authority_epoch": 1, + "now": 50, + } + assert not controls.verify_home_control_token( + db, control_token=first.control_token, **common + ) + assert controls.verify_home_control_token( + db, control_token=second.control_token, **common + ) + + +def test_peer_link_save_load_restart_and_private_repr(tmp_path): + home_db = tmp_path / "home.db" + peer_db = tmp_path / "peer.db" + _create_room(home_db) + issued = _issue(home_db) + + created = _save(peer_db, issued.control_token) + repeated = _save(peer_db, issued.control_token) + loaded = controls.load_peer_control_links(peer_db, now=30) + + assert created.idempotent is False + assert repeated.idempotent is True + assert loaded.links == (created.link,) + assert loaded.quarantined == 0 + assert loaded.truncated is False + assert issued.control_token not in repr(created) + assert issued.control_token not in repr(created.link.as_status()) + assert created.link.transport_security == "tls" + + +@pytest.mark.parametrize( + "change", + [ + {"home_url": "https://other.example.test"}, + {"authority_gateway_id": "install:gateway-b"}, + {"authority_epoch": 2}, + ], +) +def test_peer_link_conflicts_on_changed_authority_or_url(tmp_path, change): + home_db = tmp_path / "home.db" + peer_db = tmp_path / "peer.db" + _create_room(home_db) + issued = _issue(home_db) + _save(peer_db, issued.control_token) + + with pytest.raises( + controls.HostedRoomControlConflictError, match="stored authority" + ): + _save(peer_db, issued.control_token, **change) + + +def test_peer_link_conflicts_on_changed_token_without_leaking_it(tmp_path): + home_db = tmp_path / "home.db" + peer_db = tmp_path / "peer.db" + _create_room(home_db) + first = _issue(home_db) + controls.revoke_home_control_tokens(home_db, room_id="room-1", now=30) + second = _issue(home_db, now=40) + _save(peer_db, first.control_token) + + with pytest.raises(controls.HostedRoomControlConflictError) as error: + _save(peer_db, second.control_token) + assert first.control_token not in str(error.value) + assert second.control_token not in str(error.value) + + +def test_peer_link_requires_https_or_loopback(tmp_path): + strong = secrets.token_urlsafe(controls.TOKEN_BYTES) + + with pytest.raises(controls.HostedRoomControlError, match="endpoint"): + _save(tmp_path / "state.db", strong, home_url="http://peer.example.test") + saved = _save(tmp_path / "state.db", strong, home_url="http://127.0.0.1:8080") + assert saved.link.transport_security == "loopback" + + +def test_peer_control_link_requires_the_exact_live_roomlink_reservation(tmp_path): + db = tmp_path / "state.db" + claims = { + "room_id": "room-1", + "member_id": "member-1", + "target_profile": "reviewer", + "authority_gateway_id": HOME, + "authority_epoch": 1, + } + hosted_rooms.reserve_peer_room( + db, + claims=claims, + expires_at=100, + now=20, + ) + assert controls.peer_reservation_matches( + db, + **claims, + now=30, + ) + assert not controls.peer_reservation_matches( + db, + **{**claims, "member_id": "member-2"}, + now=30, + ) + assert not controls.peer_reservation_matches( + db, + **claims, + now=100, + ) + + +def test_peer_expiry_and_revocation_are_durable_and_idempotent(tmp_path): + db = tmp_path / "state.db" + token = secrets.token_urlsafe(controls.TOKEN_BYTES) + _save(db, token, expires_at=40) + + expired = controls.load_peer_control_links(db, now=40, include_inactive=True) + assert expired.links[0].status == "expired" + assert controls.load_peer_control_links(db, now=41).links == () + assert ( + controls.revoke_peer_control_links( + db, room_id="room-1", member_id="member-1", now=50 + ) + == 1 + ) + assert ( + controls.revoke_peer_control_links( + db, room_id="room-1", member_id="member-1", now=51 + ) + == 0 + ) + reloaded = controls.load_peer_control_links(db, now=60, include_inactive=True) + assert reloaded.links[0].status == "revoked" + + +def test_malformed_peer_row_is_quarantined_without_secret_in_diagnostics(tmp_path): + db = tmp_path / "state.db" + token = secrets.token_urlsafe(controls.TOKEN_BYTES) + _save(db, token) + with sqlite3.connect(db) as conn: + conn.execute( + """UPDATE hosted_room_peer_controls + SET home_url='http://public.example.test' + WHERE room_id='room-1'""" + ) + conn.commit() + + loaded = controls.load_peer_control_links(db, now=30) + assert loaded.links == () + assert loaded.quarantined == 1 + assert token not in repr(loaded) + with sqlite3.connect(db) as conn: + row = conn.execute( + """SELECT status, quarantine_reason + FROM hosted_room_peer_controls WHERE room_id='room-1'""" + ).fetchone() + assert row == ("quarantined", "invalid_stored_link") + repeated = controls.load_peer_control_links(db, now=31) + assert repeated.links == () + assert repeated.quarantined == 0 + + +def test_peer_load_is_bounded_and_reports_truncation(tmp_path): + db = tmp_path / "state.db" + token = secrets.token_urlsafe(controls.TOKEN_BYTES) + for index in range(3): + _save(db, token, room_id=f"room-{index}", member_id=f"member-{index}") + + loaded = controls.load_peer_control_links(db, limit=2, now=30) + assert len(loaded.links) == 2 + assert loaded.truncated is True + + +def test_remote_retry_plan_and_result_are_idempotent_and_conflict_safe(tmp_path): + db = tmp_path / "state.db" + first = controls.begin_control_retry( + db, + command_id="retry-1", + room_id="room-1", + member_id="member-1", + task_ids=["task-1", "task-2"], + now=20, + ) + replay = controls.begin_control_retry( + db, + command_id="retry-1", + room_id="room-1", + member_id="member-1", + task_ids=["task-1", "task-2"], + now=21, + ) + assert first.idempotent is False + assert replay.idempotent is True + assert replay.task_ids == ("task-1", "task-2") + pending = controls.load_pending_control_retries(db, room_id="room-1") + assert [(item.command_id, item.member_id, item.task_ids) for item in pending] == [ + ("retry-1", "member-1", ("task-1", "task-2")) + ] + + completed = controls.complete_control_retry( + db, + command_id="retry-1", + result={"retried": 2}, + now=22, + ) + assert completed == {"retried": 2} + assert controls.load_pending_control_retries(db, room_id="room-1") == () + final = controls.begin_control_retry( + db, + command_id="retry-1", + room_id="room-1", + member_id="member-1", + task_ids=["task-1", "task-2"], + now=23, + ) + assert final.result == {"retried": 2} + + with pytest.raises(controls.HostedRoomControlConflictError): + controls.begin_control_retry( + db, + command_id="retry-1", + room_id="room-1", + member_id="member-1", + task_ids=["task-3"], + now=24, + ) + + +@pytest.mark.parametrize( + "stored_task_ids", + ["not-json", '{"task-real":true}', '"task-real"'], +) +def test_pending_retry_loader_quarantines_malformed_rows( + tmp_path, + stored_task_ids, +): + db = tmp_path / "state.db" + controls.begin_control_retry( + db, + command_id="retry-invalid", + room_id="room-1", + member_id="member-1", + task_ids=["task-1"], + now=20, + ) + with sqlite3.connect(db) as conn: + conn.execute( + """UPDATE hosted_room_control_commands + SET task_ids_json=? + WHERE command_id='retry-invalid'""", + (stored_task_ids,), + ) + conn.commit() + + assert controls.load_pending_control_retries(db, room_id="room-1") == () + with sqlite3.connect(db) as conn: + row = conn.execute( + """SELECT state, result_json + FROM hosted_room_control_commands + WHERE command_id='retry-invalid'""" + ).fetchone() + assert row[0] == "completed" + assert json.loads(row[1]) == { + "action": "retry", + "error": "invalid_stored_plan", + "retried": 0, + } + + +def test_failed_retry_rotation_does_not_starve_newer_commands(tmp_path): + db = tmp_path / "state.db" + for index in range(9): + controls.begin_control_retry( + db, + command_id=f"retry-{index}", + room_id="room-1", + member_id="member-1", + task_ids=[f"task-{index}"], + now=20 + index, + ) + first = controls.load_pending_control_retries(db, room_id="room-1", limit=8) + assert [item.command_id for item in first] == [f"retry-{index}" for index in range(8)] + for item in first: + assert controls.defer_control_retry( + db, + command_id=item.command_id, + now=100, + ) + + rotated = controls.load_pending_control_retries(db, room_id="room-1", limit=8) + assert rotated[0].command_id == "retry-8" + + +def test_concurrent_home_issuance_allows_only_one_token_per_scope(tmp_path): + db = tmp_path / "state.db" + _create_room(db) + + def issue(_index): + try: + return _issue(db).control_token + except controls.HostedRoomControlConflictError: + return None + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(issue, range(16))) + assert len([token for token in results if token is not None]) == 1 + + +def test_concurrent_peer_link_saves_do_not_lose_records(tmp_path): + db = tmp_path / "state.db" + token = secrets.token_urlsafe(controls.TOKEN_BYTES) + + def save(index): + return _save( + db, + token, + room_id=f"room-{index}", + member_id=f"member-{index}", + home_url=f"https://home-{index}.example.test", + ) + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(save, range(24))) + assert all(not result.idempotent for result in results) + assert len(controls.load_peer_control_links(db, now=30).links) == 24 + + +def test_state_database_permissions_are_owner_only_best_effort(tmp_path): + db = tmp_path / "state.db" + token = secrets.token_urlsafe(controls.TOKEN_BYTES) + _save(db, token) + if os.name == "posix": + assert stat.S_IMODE(db.stat().st_mode) == 0o600 diff --git a/tests/gateway/test_hosted_room_driver.py b/tests/gateway/test_hosted_room_driver.py index 65b6eaed7e60a..69bbc057efadb 100644 --- a/tests/gateway/test_hosted_room_driver.py +++ b/tests/gateway/test_hosted_room_driver.py @@ -514,6 +514,122 @@ def test_release_fails_closed_while_its_task_is_running(db): assert driver.release_lease(db, lease, clock=clock)["idempotent"] is False +def test_active_lease_revalidation_does_not_extend_expiry(db): + clock = FakeClock() + lease = _lease(db, clock, ttl=5) + + validated = driver.require_active_lease(db, lease, clock=clock) + + assert validated == lease + clock.advance(5) + with pytest.raises(driver.StaleLeaseError): + driver.require_active_lease(db, lease, clock=clock) + + +def test_start_task_atomically_honors_room_stop_fence(db): + clock = FakeClock() + identity = _identity() + lease = _lease(db, clock) + rooms.append_event( + db, + room_id="room-1", + event_id="user-before-start", + kind="message.user", + actor={"kind": "user", "id": "desktop"}, + payload={"text": "Start", "thread_id": "thread-1"}, + authority_gateway_id="gateway-a", + authority_epoch=1, + ) + _admit(db, identity, clock) + rooms.request_room_stop( + db, + room_id="room-1", + cancel_id="stop-before-start", + expected_gateway_id="gateway-a", + expected_epoch=1, + ) + + assert ( + driver.start_task( + db, + identity, + lease, + expected_cancel_generation=0, + clock=clock, + ) + is None + ) + assert driver.get_task(db, identity)["status"] == "cancelled" + + +@pytest.mark.parametrize("retry_state", ["deferred", "indeterminate"]) +def test_retry_atomically_honors_room_stop_fence(db, retry_state): + clock = FakeClock() + identity = _identity() + first = _lease(db, clock, ttl=5) + rooms.append_event( + db, + room_id="room-1", + event_id="user-before-retry", + kind="message.user", + actor={"kind": "user", "id": "desktop"}, + payload={"text": "Retry", "thread_id": "thread-1"}, + authority_gateway_id="gateway-a", + authority_epoch=1, + ) + _admit(db, identity, clock) + original = driver.start_task( + db, + identity, + first, + expected_cancel_generation=0, + clock=clock, + ) + assert original is not None + clock.advance(5) + recovered = _lease(db, clock, process="new-process", ttl=30) + driver.recover_room(db, recovered, clock=clock) + if retry_state == "deferred": + driver.defer_indeterminate_task( + db, + identity, + recovered, + expected_execution_generation=original.execution_generation, + expected_cancel_generation=original.cancel_generation, + reason="member_unavailable", + clock=clock, + ) + rooms.request_room_stop( + db, + room_id="room-1", + cancel_id="stop-before-retry", + expected_gateway_id="gateway-a", + expected_epoch=1, + ) + + if retry_state == "deferred": + retried = driver.requeue_deferred_task( + db, + identity, + recovered, + expected_execution_generation=original.execution_generation, + expected_cancel_generation=original.cancel_generation, + clock=clock, + ) + else: + retried = driver.requeue_indeterminate_task( + db, + identity, + recovered, + expected_execution_generation=original.execution_generation, + expected_cancel_generation=original.cancel_generation, + clock=clock, + ) + + assert retried["status"] == "cancelled" + assert retried["cancel_id"].startswith("stop-fence:") + + def test_restart_recovery_never_requeues_indeterminate_work(db): clock = FakeClock() running = _identity() @@ -617,6 +733,18 @@ def test_current_lease_can_commit_verified_indeterminate_receipt(db): result={"text": "recovered"}, clock=clock, ) + repeated = driver.resolve_indeterminate_task( + db, + running, + recovered, + expected_execution_generation=attempt.execution_generation, + expected_cancel_generation=attempt.cancel_generation, + settlement_id="recovered-receipt", + status="settled", + result={"text": "recovered"}, + clock=clock, + retry_id="retry-terminal", + ) next_attempt = driver.start_task( db, queued, @@ -626,6 +754,13 @@ def test_current_lease_can_commit_verified_indeterminate_receipt(db): ) assert settled["status"] == "settled" + assert repeated["idempotent"] is True + assert driver.retry_receipt_exists( + db, + room_id=running.room_id, + task_id=running.task_id, + retry_id="retry-terminal", + ) assert next_attempt.execution_generation == 1 @@ -662,11 +797,18 @@ def test_current_lease_can_commit_verified_indeterminate_cancellation(db): expected_cancel_generation=attempt.cancel_generation, cancel_id="remote-cancel:1", clock=clock, + retry_id="retry-cancelled", ) assert cancelled["status"] == "cancelled" assert cancelled["execution_generation"] == attempt.execution_generation assert repeated["idempotent"] is True + assert driver.retry_receipt_exists( + db, + room_id=running.room_id, + task_id=running.task_id, + retry_id="retry-cancelled", + ) def test_indeterminate_retry_is_explicit_and_advances_execution_generation(db): @@ -1069,6 +1211,45 @@ def test_pre_deferred_schema_is_migrated_without_losing_tasks(db): assert "'deferred'" in table_sql +def test_draft_retry_id_column_is_migrated_to_receipt_ledger(db): + clock = FakeClock() + identity = _identity() + _admit(db, identity, clock) + + with sqlite3.connect(db) as conn: + conn.execute("ALTER TABLE hosted_room_driver_tasks ADD COLUMN retry_id TEXT") + conn.execute( + """INSERT INTO hosted_room_retry_receipts( + retry_id, room_id, task_id, source_execution_generation, created_at + ) VALUES ('existing-retry', ?, ?, 0, 100)""", + (identity.room_id, identity.task_id), + ) + conn.execute( + "UPDATE hosted_room_driver_tasks SET retry_id='draft-retry'" + ) + conn.commit() + + assert driver.get_task(db, identity)["status"] == "queued" + with sqlite3.connect(db) as conn: + columns = { + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_driver_tasks)") + } + assert "retry_id" not in columns + assert driver.retry_receipt_exists( + db, + room_id=identity.room_id, + task_id=identity.task_id, + retry_id="draft-retry", + ) + assert driver.retry_receipt_exists( + db, + room_id=identity.room_id, + task_id=identity.task_id, + retry_id="existing-retry", + ) + + def test_first_schema_creation_is_safe_across_processes(db): with sqlite3.connect(db) as conn: conn.execute("DROP TABLE IF EXISTS hosted_room_driver_tasks") diff --git a/tests/gateway/test_hosted_room_exact_grant_revoke.py b/tests/gateway/test_hosted_room_exact_grant_revoke.py new file mode 100644 index 0000000000000..b773dc2c284aa --- /dev/null +++ b/tests/gateway/test_hosted_room_exact_grant_revoke.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from gateway import hosted_rooms + + +def claims(grant_id: str, issued_at: float) -> dict: + return { + "grant_id": grant_id, + "room_id": "room-1", + "home_install_id": "install:home", + "authority_gateway_id": "install:home", + "authority_epoch": 1, + "member_id": "builder", + "target_install_id": "install:peer", + "target_profile": "builder", + "issued_at": issued_at, + } + + +def test_exact_revoke_preserves_a_concurrent_replacement(tmp_path): + db = tmp_path / "state.db" + losing = claims("grant-losing", 100.0) + winning = claims("grant-winning", 101.0) + other_scope = {**losing, "member_id": "reviewer"} + + hosted_rooms.revoke_room_grant_id( + db, + claims=losing, + expires_at=300.0, + now=110.0, + ) + + assert hosted_rooms.room_grant_is_revoked(db, claims=losing, now=120.0) + assert not hosted_rooms.room_grant_is_revoked(db, claims=winning, now=120.0) + assert not hosted_rooms.room_grant_is_revoked(db, claims=other_scope, now=120.0) + + +def test_scope_revoke_still_fences_all_older_grants(tmp_path): + db = tmp_path / "state.db" + first = claims("grant-first", 100.0) + second = claims("grant-second", 101.0) + + hosted_rooms.revoke_room_grant_scope( + db, + claims=first, + expires_at=300.0, + now=110.0, + ) + + assert hosted_rooms.room_grant_is_revoked(db, claims=first, now=120.0) + assert hosted_rooms.room_grant_is_revoked(db, claims=second, now=120.0) diff --git a/tests/gateway/test_hosted_room_messaging.py b/tests/gateway/test_hosted_room_messaging.py new file mode 100644 index 0000000000000..2a76b0d30f34f --- /dev/null +++ b/tests/gateway/test_hosted_room_messaging.py @@ -0,0 +1,1557 @@ +"""Messaging controls for gateway-hosted Group Chats.""" + +from __future__ import annotations + +import hashlib +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway import hosted_room_controls, hosted_room_driver, hosted_room_messaging, hosted_rooms +from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig +from gateway.hosted_room_messaging import ( + MessagingRoomBackend, + RoomControlError, + format_room_bot_detail, + format_room_bot_list, + format_room_detail, + format_room_list, + list_messaging_rooms, + messaging_actor, + messaging_event_id, + parse_room_command, + relay_provenance_is_unknown, + resolve_room, + resolve_room_picker_choice, + room_bot_picker_choices, + room_picker_choices, + retry_room, + send_to_room, + stop_room, +) +from gateway.platforms.base import MessageEvent, MessageType, SendResult +from gateway.session import SessionSource +from hermes_cli.commands import ( + is_interrupt_then_dispatch, + resolve_command, + should_bypass_active_session, +) +from tui_gateway.hosted_room_service import HostedRoomService + + +class _FakeService: + def __init__(self, db_path): + self.db_path = db_path + self.sent = [] + self.stopped = [] + self.retried = [] + self.room_status = {"running": True, "working": False, "blocked": False} + + def status(self, _room_id): + return dict(self.room_status) + + def send(self, **kwargs): + self.sent.append(kwargs) + return {"seq": 1} + + def stop_room(self, room_id, *, cancel_id): + self.stopped.append((room_id, cancel_id)) + return 2 + + def retry_room_task(self, room_id, *, task_id, retry_id=None): + self.retried.append((room_id, task_id, retry_id)) + return {"status": "queued"} + + +class _TestHostedRoomService(HostedRoomService): + """Hosted service with a fixed two-profile test roster.""" + + def __init__(self, db_path): + super().__init__(ModuleType("test_hosted_room_server"), db_path=db_path) + + def local_profiles(self) -> tuple[str, ...]: + return ("default", "ops") + + +class _ImmediateRPC: + """In-process room worker transport that settles without a model call.""" + + def __init__(self): + self.sessions = {} + + def resolve_exact(self, *, profile, title, source): + return self.sessions.get((profile, title)) + + def create(self, *, profile, title, source): + session = {"session_id": f"{profile}-session", "title": title} + self.sessions[(profile, title)] = session + return session + + def resume(self, *, profile, session_id, source): + return {"session_id": session_id} + + def submit( + self, + *, + profile, + session_id, + prompt, + source, + task, + execution_generation, + on_terminal, + ): + on_terminal({"status": "settled", "text": f"reply from {profile}"}) + return {"accepted": True} + + def history(self, *, profile, session_id, source): + return [] + + def info(self, *, profile, session_id, source): + return {"active": False, "task_id": None} + + def interrupt(self, *, profile, session_id, source, expected_task_id): + return {"interrupted": True} + + +class _HoldingRPC(_ImmediateRPC): + """Keep a turn active until the owner observes a durable stop intent.""" + + def __init__(self): + super().__init__() + self.active = {} + self.interrupts = [] + + def create(self, *, profile, title, source): + session = super().create(profile=profile, title=title, source=source) + self.active[session["session_id"]] = {"active": False, "task_id": None} + return session + + def submit( + self, + *, + profile, + session_id, + prompt, + source, + task, + execution_generation, + on_terminal, + ): + self.active[session_id] = {"active": True, "task_id": task.task_id} + return {"accepted": True} + + def info(self, *, profile, session_id, source): + return dict(self.active[session_id]) + + def interrupt(self, *, profile, session_id, source, expected_task_id): + state = self.active[session_id] + if state["task_id"] != expected_task_id: + return {"interrupted": False} + state["active"] = False + self.interrupts.append(expected_task_id) + return {"interrupted": True} + + +def _wait_for(predicate, timeout=2.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.01) + raise AssertionError("condition was not reached") + + +def _seed_rooms(tmp_path): + db = tmp_path / "state.db" + authority = "install:test-gateway" + first = hosted_rooms.create_room( + db, + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": "ops", "handle": "ops", "display_name": "Operations"}, + ], + authority_gateway_id=authority, + ) + second = hosted_rooms.create_room( + db, + room_id="research-room", + name="Research room", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": "research", "handle": "research"}, + ], + authority_gateway_id=authority, + ) + return db, first, second + + +def test_secondary_profile_only_lists_rooms_in_its_frozen_roster(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + + assert {room["room_id"] for room in list_messaging_rooms(service)} == { + "release-room", + "research-room", + } + assert [ + room["room_id"] + for room in list_messaging_rooms(service, profile="ops") + ] == ["release-room"] + assert [ + room["room_id"] + for room in list_messaging_rooms(service, profile="research") + ] == ["research-room"] + + +def _event( + text: str, + *, + platform: Platform = Platform.SIGNAL, + user_id: str = "user-1", + message_id: str = "message-1", + media: bool = False, + media_urls: list[str] | None = None, + media_types: list[str] | None = None, + is_bot: bool = False, + chat_type: str = "dm", + is_one_to_one: bool | None = True, +) -> MessageEvent: + source = SessionSource( + platform=platform, + chat_id=f"chat-{platform.value}", + chat_type=chat_type, + user_id=user_id, + user_name="Display Name", + is_bot=is_bot, + ) + source.is_one_to_one = is_one_to_one + return MessageEvent( + text=text, + message_type=MessageType.COMMAND, + user_id=user_id, + user_name="Display Name", + message_id=message_id, + media_urls=media_urls if media_urls is not None else ["/tmp/image.png"] if media else [], + media_types=media_types if media_types is not None else ["image/png"] if media else [], + source=source, + ) + + +def _runner(*, platform: Platform = Platform.SIGNAL, extra=None): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + effective_extra = ( + {"allow_admin_from": ["user-1"]} if extra is None else extra + ) + runner.config = GatewayConfig( + platforms={ + platform: PlatformConfig( + enabled=True, + token="***", + extra=effective_extra, + ) + } + ) + runner.adapters = { + platform: SimpleNamespace( + typed_command_prefix="!" if platform in {Platform.MATRIX, Platform.SLACK} else "/" + ) + } + return runner + + +class _PickerAdapter: + typed_command_prefix = "/" + + def __init__(self): + self.calls = [] + + async def send_choice_picker(self, **kwargs): + self.calls.append(kwargs) + return SendResult(success=True, message_id="picker-1") + + +def _seed_classic_projection(home, *, room_id="classic-room"): + import yaml + + home.mkdir(parents=True, exist_ok=True) + (home / "profile.yaml").write_text( + yaml.safe_dump( + { + "ui_meta": { + "hermes-bots-groups": { + "version": 3, + "updatedAt": 2000, + "rooms": { + f"id:{room_id}": { + "name": "Desktop planning", + "roomId": room_id, + "hosted": None, + "desktopAuthorityHash": hashlib.sha256( + b"authority:test" + ).hexdigest(), + "members": [ + {"name": "default", "handle": "hermes"}, + {"name": "reviewer", "handle": "reviewer"}, + ], + "log": [ + { + "id": "message-1", + "from": {"kind": "user", "name": "You"}, + "text": "Review the plan", + "at": 1000, + "thread": "thread-1", + }, + { + "id": "message-2", + "from": {"kind": "member", "name": "Reviewer"}, + "text": "The rollout needs a rollback step.", + "at": 2000, + "thread": "thread-1", + }, + ], + } + }, + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + +def test_room_commands_are_gateway_dispatchable_without_interrupting_agent(): + group = resolve_command("group") + assert group is not None + assert group.gateway_only is True + assert group.busy_policy == "dispatch" + assert group.subcommands == () + assert should_bypass_active_session("group") is True + assert is_interrupt_then_dispatch("group") is False + assert resolve_command("groups") is None + assert resolve_command("rooms") is None + + +def test_classic_room_projection_is_listed_with_recent_activity(tmp_path, monkeypatch): + home = tmp_path / "hermes" + _seed_classic_projection(home) + monkeypatch.setenv("HERMES_HOME", str(home)) + service = _FakeService(tmp_path / "state.db") + + rooms = list_messaging_rooms(service) + + assert len(rooms) == 1 + assert rooms[0]["_room_mode"] == "desktop" + assert rooms[0]["desktop_available"] is False + detail = format_room_detail(service, rooms[0]) + assert "💬 **Desktop planning**" in detail + assert "🟡 waiting for Desktop" in detail + assert "• **Reviewer:** The rollout needs a rollback step." in detail + listing = format_room_list(service) + assert listing.startswith("👥 **Group Chats**\n") + assert "🧭 **Controls**\nCheck: `/group `" in listing + assert "Send: `/group send `" in listing + assert "Stop: `/group stop`" in listing + + +@pytest.mark.asyncio +async def test_routed_profile_lists_its_own_classic_group_chats(tmp_path, monkeypatch): + home = tmp_path / "hermes" + _seed_classic_projection(home / "profiles" / "worker", room_id="worker-room") + monkeypatch.setenv("HERMES_HOME", str(home)) + service = _FakeService(tmp_path / "state.db") + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group") + event.source.profile = "worker" + + listing = await _runner()._handle_rooms_command(event) + + assert "Desktop planning" in listing + assert "No Group Chats yet" not in listing + + +@pytest.mark.asyncio +async def test_name_keyed_legacy_group_chat_cannot_be_mutated_by_stale_number( + tmp_path, monkeypatch +): + import yaml + + home = tmp_path / "hermes" + _seed_classic_projection(home) + profile = home / "profile.yaml" + raw = yaml.safe_load(profile.read_text(encoding="utf-8")) + snapshot = raw["ui_meta"]["hermes-bots-groups"] + snapshot["version"] = 2 + legacy = snapshot["rooms"].pop("id:classic-room") + legacy.pop("roomId") + snapshot["rooms"] = {"Desktop planning": legacy} + profile.write_text(yaml.safe_dump(raw), encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + service = _FakeService(tmp_path / "state.db") + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_rooms_command( + _event("/group 1 send do not misroute", message_id="legacy-number") + ) + + assert result == ( + "Open this older Group Chat once in the latest Hermes Desktop before " + "changing it from messaging." + ) + + +def test_participant_gateway_lists_reads_and_controls_remote_room( + tmp_path, monkeypatch +): + db = tmp_path / "state.db" + now = time.time() + hosted_room_controls.save_peer_control_link( + db, + room_id="remote-room", + member_id="reviewer", + room_name="Release planning", + member_count=2, + home_url="https://home.example.test", + authority_gateway_id="install:home", + authority_epoch=3, + control_token="A" * 43, + expires_at=now + 600, + now=now, + ) + calls = [] + + class _RemoteClient: + def __init__(self, link): + assert link.control_token == "A" * 43 + + def summary(self): + return { + "room": { + "room_id": "remote-room", + "name": "Release planning", + "members": [ + {"member_id": "author", "display_name": "Author"}, + {"member_id": "reviewer", "display_name": "Reviewer"}, + ], + "authority_gateway_id": "install:home", + "authority_epoch": 3, + }, + "status": {"working": True, "blocked": False, "counts": {}}, + "events": [ + { + "kind": "message.member", + "actor": {"id": "reviewer"}, + "payload": {"member_id": "reviewer", "text": "Ready."}, + } + ], + } + + def mutate(self, **kwargs): + calls.append(kwargs) + if kwargs["action"] == "retry": + return {"action": "retry", "processed": 1} + return {"action": kwargs["action"]} + + monkeypatch.setattr(hosted_room_messaging, "RoomControlHTTPClient", _RemoteClient) + monkeypatch.setattr( + hosted_rooms, + "local_authority_gateway_id", + lambda: "install:participant", + ) + service = _FakeService(db) + + rooms = list_messaging_rooms(service) + assert [(room["name"], room["_room_mode"], room["member_count"]) for room in rooms] == [ + ("Release planning", "remote", 2) + ] + assert "⚪ **1. Release planning** · connected · 2 Bots" in format_room_list(service) + detail = format_room_detail(service, rooms[0]) + assert "💬 **Release planning**" in detail + assert "🟡 work queued or running" in detail + assert "• **Reviewer:** Ready." in detail + assert "A" * 43 not in repr(rooms) + assert send_to_room( + service, + rooms[0], + _event("/group 1 send hello", message_id="remote-send"), + "hello", + ) == "Queued in Release planning." + assert stop_room( + service, + rooms[0], + _event("/group 1 stop", message_id="remote-stop"), + ) == "Stop requested for Release planning. Active work will stop safely." + assert retry_room( + service, + rooms[0], + _event("/group 1 retry", message_id="remote-retry"), + ) == "Retry checked for Release planning (1 task)." + assert [call["action"] for call in calls] == ["send", "stop", "retry"] + assert calls[0]["actor_display_name"] == "Display Name via Signal" + + +def test_legacy_projection_uses_the_same_name_identity_as_new_desktop(tmp_path, monkeypatch): + import yaml + + home = tmp_path / "hermes" + home.mkdir(parents=True) + (home / "profile.yaml").write_text( + yaml.safe_dump( + { + "ui_meta": { + "hermes-bots-groups": { + "version": 2, + "rooms": { + "Legacy planning": { + "name": "Legacy planning", + "members": [{"name": "default"}], + "log": [], + } + }, + } + } + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + room = list_messaging_rooms(_FakeService(tmp_path / "state.db"))[0] + + assert room["room_id"] == "name:Legacy planning" + + +def test_more_than_128_classic_rooms_list_without_disabling_controls(tmp_path, monkeypatch): + import yaml + + home = tmp_path / "hermes" + home.mkdir(parents=True) + rooms = { + f"id:room-{index}": { + "name": f"Group chat {index}", + "roomId": f"room-{index}", + "members": [{"name": "default"}], + "log": [], + } + for index in range(260) + } + (home / "profile.yaml").write_text( + yaml.safe_dump( + {"ui_meta": {"hermes-bots-groups": {"version": 3, "rooms": rooms}}} + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + listed = list_messaging_rooms(_FakeService(tmp_path / "state.db")) + + assert len(listed) == 260 + assert len({room["messaging_ref"] for room in listed}) == 260 + + +def test_malformed_projected_room_does_not_hide_healthy_group_chats(tmp_path, monkeypatch): + import yaml + + home = tmp_path / "hermes" + _seed_classic_projection(home) + profile = home / "profile.yaml" + raw = yaml.safe_load(profile.read_text(encoding="utf-8")) + raw["ui_meta"]["hermes-bots-groups"]["rooms"]["id:broken"] = { + "name": "Broken", + "roomId": "x" * 201, + "desktopAuthorityHash": hashlib.sha256(b"authority:broken").hexdigest(), + "members": [{"name": "default"}], + "log": [], + } + profile.write_text(yaml.safe_dump(raw), encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + + rooms = list_messaging_rooms(_FakeService(tmp_path / "state.db")) + + assert [room["room_id"] for room in rooms] == ["classic-room"] + + +def test_classic_room_send_and_stop_wait_for_desktop(tmp_path, monkeypatch): + from gateway import desktop_room_mailbox + + home = tmp_path / "hermes" + _seed_classic_projection(home) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr( + hosted_rooms, + "local_authority_gateway_id", + lambda: "install:test-gateway", + ) + service = _FakeService(tmp_path / "state.db") + room = list_messaging_rooms(service)[0] + + sent = send_to_room( + service, + room, + _event("/group 1 send hello", message_id="send-1"), + "hello", + ) + + assert sent == "Saved for Desktop planning. Open or update Hermes Desktop to continue." + commands = desktop_room_mailbox.claim_commands( + desktop_room_mailbox.default_db_path(), + consumer_id="desktop:test", + room_authorities=[{ + "room_id": "classic-room", + "authority_token": "authority:test", + }], + ) + assert [(item["action"], item["payload"]) for item in commands] == [ + ( + "send", + { + "actor_display_name": "Display Name via Signal", + "message": "hello", + "recipients": [ + {"handle": "hermes", "name": "default"}, + {"handle": "reviewer", "name": "reviewer"}, + ], + }, + ) + ] + stopped = stop_room( + service, + room, + _event("/group 1 stop", message_id="stop-1"), + ) + assert stopped == ( + "Stop saved for Desktop planning. Open or update Hermes Desktop to apply it." + ) + stop_commands = desktop_room_mailbox.claim_commands( + desktop_room_mailbox.default_db_path(), + consumer_id="desktop:test", + room_authorities=[{ + "room_id": "classic-room", + "authority_token": "authority:test", + }], + actions=["stop"], + ) + assert [(item["action"], item["payload"]) for item in stop_commands] == [ + ( + "stop", + { + "target_command_id": commands[0]["command_id"], + "target_thread_id": "thread-1", + }, + ) + ] + + +def test_legacy_desktop_room_control_requests_one_current_desktop_open(tmp_path, monkeypatch): + import yaml + + home = tmp_path / "hermes" + _seed_classic_projection(home) + profile = home / "profile.yaml" + raw = yaml.safe_load(profile.read_text(encoding="utf-8")) + del raw["ui_meta"]["hermes-bots-groups"]["rooms"]["id:classic-room"][ + "desktopAuthorityHash" + ] + profile.write_text(yaml.safe_dump(raw), encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + room = list_messaging_rooms(_FakeService(tmp_path / "state.db"))[0] + + with pytest.raises(RoomControlError, match="Open this Group Chat once"): + send_to_room( + _FakeService(tmp_path / "state.db"), + room, + _event("/group 1 send hello", message_id="legacy-send"), + "hello", + ) + + +def test_classic_room_detail_surfaces_failed_command_recovery(tmp_path, monkeypatch): + from gateway import desktop_room_mailbox + + home = tmp_path / "hermes" + _seed_classic_projection(home) + monkeypatch.setenv("HERMES_HOME", str(home)) + db = desktop_room_mailbox.default_db_path() + desktop_room_mailbox.enqueue_command( + db, + command_id="messaging:failed", + room_id="classic-room", + authority_hash=hashlib.sha256(b"authority:test").hexdigest(), + action="send", + payload={"message": "hello"}, + ) + claimed = desktop_room_mailbox.claim_commands( + db, + consumer_id="desktop:test", + room_authorities=[{ + "room_id": "classic-room", + "authority_token": "authority:test", + }], + )[0] + desktop_room_mailbox.complete_command( + db, + consumer_id="desktop:test", + command_id="messaging:failed", + lease_token=claimed["lease_token"], + success=False, + result={"message": "route missing"}, + ) + service = _FakeService(tmp_path / "state.db") + room = list_messaging_rooms(service)[0] + + detail = format_room_detail(service, room) + + assert "💬 **Desktop planning**" in detail + assert "⚠️ needs attention" in detail + assert "The latest command could not be applied." in detail + + +def test_classic_retry_requeues_all_expired_commands_and_replays_receipt( + tmp_path, monkeypatch +): + from gateway import desktop_room_mailbox + + home = tmp_path / "hermes" + _seed_classic_projection(home) + monkeypatch.setenv("HERMES_HOME", str(home)) + db = desktop_room_mailbox.default_db_path() + now = [100.0] + for index in range(2): + desktop_room_mailbox.enqueue_command( + db, + command_id=f"messaging:expired-{index}", + room_id="classic-room", + authority_hash=hashlib.sha256(b"authority:test").hexdigest(), + action="send", + payload={"message": str(index)}, + clock=lambda: now[0], + ) + now[0] += 1 + now[0] += desktop_room_mailbox.PENDING_TTL_SECONDS + 1 + assert desktop_room_mailbox.claim_commands( + db, + consumer_id="desktop:test", + room_authorities=[{ + "room_id": "classic-room", + "authority_token": "authority:test", + }], + clock=lambda: now[0], + ) == [] + + service = _FakeService(db) + room = list_messaging_rooms(service)[0] + event = _event("/group 1 retry", message_id="retry-expired") + result = retry_room(service, room, event) + replay = retry_room(service, room, event) + + assert result == "Retry queued for Desktop planning (2 commands)." + assert replay == result + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ('1 send "hi there"', ("send", "1", "hi there")), + ("1 send -- quoted style", ("send", "1", "quoted style")), + ("1 stop", ("stop", "1", "")), + ("1 retry", ("retry", "1", "")), + ], +) +def test_parse_room_command_keeps_names_and_message_content(raw, expected): + parsed = parse_room_command(raw) + assert (parsed.action, parsed.room_query, parsed.message) == expected + + +@pytest.mark.parametrize("raw", ["", "send room", "send -- hello", "stop"]) +def test_parse_room_command_returns_actionable_usage(raw): + with pytest.raises(RoomControlError, match="Use `/group"): + parse_room_command(raw) + + +def test_room_resolution_is_exact_then_unique_prefix_then_substring(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + rooms = hosted_rooms.list_rooms(db) + assert resolve_room(rooms, "release-room")["name"] == "Release room" + assert resolve_room(rooms, "Research")["room_id"] == "research-room" + assert resolve_room(rooms, "lease")["room_id"] == "release-room" + + +def test_room_numbers_stay_stable_and_are_not_reused_after_disband(tmp_path): + db, first, second = _seed_rooms(tmp_path) + service = _FakeService(db) + initial = { + room["room_id"]: room["messaging_ref"] + for room in list_messaging_rooms(service) + } + + hosted_rooms.disband_room( + db, + room_id=first["room_id"], + expected_gateway_id="install:test-gateway", + expected_epoch=1, + ) + third = hosted_rooms.create_room( + db, + room_id="stop-signals", + name="Stop signals", + members=[], + authority_gateway_id="install:test-gateway", + ) + current = list_messaging_rooms(service) + refs = {room["room_id"]: room["messaging_ref"] for room in current} + + assert refs[second["room_id"]] == initial[second["room_id"]] + assert refs[third["room_id"]] > max(initial.values()) + assert resolve_room(current, str(refs[third["room_id"]]))["name"] == "Stop signals" + + +def test_missing_room_number_fails_closed_without_matching_a_numeric_name(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + hosted_rooms.create_room( + db, + room_id="roadmap-2026", + name="2026 roadmap", + members=[], + authority_gateway_id="install:test-gateway", + ) + rooms = list_messaging_rooms(_FakeService(db)) + + with pytest.raises(RoomControlError, match="numbered 2026"): + resolve_room(rooms, "2026") + + +def test_numeric_internal_id_requires_an_explicit_advanced_escape(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + numeric_id = hosted_rooms.create_room( + db, + room_id="1", + name="Legacy numeric ID", + members=[], + authority_gateway_id="install:test-gateway", + ) + rooms = list_messaging_rooms(_FakeService(db)) + + assert resolve_room(rooms, "1")["room_id"] != numeric_id["room_id"] + assert resolve_room(rooms, "id:1")["room_id"] == numeric_id["room_id"] + + +def test_room_numbers_allocate_once_across_concurrent_gateway_processes(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + + def load_refs(_index): + service = _FakeService(db) + return { + room["room_id"]: room["messaging_ref"] + for room in list_messaging_rooms(service) + } + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(load_refs, range(24))) + + assert all(result == results[0] for result in results) + assert len(set(results[0].values())) == 2 + + +def test_room_resolution_explains_ambiguity_and_missing_names(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + hosted_rooms.create_room( + db, + room_id="release-notes", + name="Release notes", + members=[], + authority_gateway_id="install:test-gateway", + ) + rooms = hosted_rooms.list_rooms(db) + with pytest.raises(RoomControlError, match="matches several group chats"): + resolve_room(rooms, "release") + with pytest.raises(RoomControlError, match="No group chat"): + resolve_room(rooms, "missing") + + +@pytest.mark.asyncio +async def test_reserved_word_room_name_opens_detail_without_triggering_stop( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + hosted_rooms.create_room( + db, + room_id="stop-signals", + name="Stop signals", + members=[], + authority_gateway_id="install:test-gateway", + ) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_rooms_command(_event("/group Stop signals")) + + assert result.startswith("💬 **Stop signals**\n") + assert service.stopped == [] + + +@pytest.mark.asyncio +async def test_numeric_action_command_wins_over_a_command_shaped_room_name( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + hosted_rooms.create_room( + db, + room_id="command-shaped-room", + name="1 stop", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": "ops", "handle": "ops"}, + ], + authority_gateway_id="install:test-gateway", + ) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_rooms_command( + _event("/group 1 stop", message_id="stop-command-shaped") + ) + + assert result.startswith("Stop requested for Release room") + assert service.stopped[0][0] == "release-room" + + +@pytest.mark.asyncio +async def test_group_list_keyword_uses_the_same_helpful_listing(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: _FakeService(db), + ) + + result = await _runner()._handle_rooms_command(_event("/group list")) + + assert result.startswith("👥 **Group Chats**\n") + assert "🧭 **Controls**\nCheck: `/group `" in result + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "platform", + [Platform.TELEGRAM, Platform.DISCORD, Platform.MATRIX], +) +async def test_bare_group_uses_native_picker_and_selection_refreshes_detail( + tmp_path, + monkeypatch, + platform, +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + adapter = _PickerAdapter() + runner = _runner(platform=platform) + runner.adapters[platform] = adapter + runner._thread_metadata_for_source = lambda source, anchor=None: {} + runner._reply_anchor_for_event = lambda event: None + + result = await runner._handle_rooms_command( + _event("/group", platform=platform) + ) + + assert result is None + assert len(adapter.calls) == 1 + call = adapter.calls[0] + assert call["title"].startswith("👥 Group Chats\n") + assert all(choice["value"].startswith("room-") for choice in call["choices"]) + assert len({choice["value"] for choice in call["choices"]}) == 2 + release_value = next( + choice["value"] for choice in call["choices"] if "Release" in choice["label"] + ) + + detail = await call["on_choice_selected"]("chat-telegram", release_value) + + assert "💬 **Release room**" in detail + assert "🤖 **Bots**" in detail + assert "🧭 **Controls**" in detail + + hosted_rooms.disband_room( + db, + room_id="release-room", + expected_gateway_id="install:test-gateway", + expected_epoch=1, + ) + missing = await call["on_choice_selected"]("chat-telegram", release_value) + assert missing == "This Group Chat is no longer available. Run the command again." + + +@pytest.mark.asyncio +async def test_group_bots_drills_into_native_participant_picker( + tmp_path, + monkeypatch, +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + adapter = _PickerAdapter() + runner = _runner(platform=Platform.TELEGRAM) + runner.adapters[Platform.TELEGRAM] = adapter + runner._thread_metadata_for_source = lambda source, anchor=None: {} + runner._reply_anchor_for_event = lambda event: None + + result = await runner._handle_rooms_command( + _event("/group 1 bots", platform=Platform.TELEGRAM) + ) + + assert result is None + call = adapter.calls[0] + assert call["title"].startswith("🤖 Bots\n") + assert all(choice["value"].startswith("p=") for choice in call["choices"]) + ops_value = next( + choice["value"] for choice in call["choices"] if "Operations" in choice["label"] + ) + detail = await call["on_choice_selected"]("chat-telegram", ops_value) + assert detail.startswith("🤖 **Operations**") + assert "`@ops`" in detail + + +@pytest.mark.asyncio +async def test_group_bot_controls_fall_back_to_rich_text(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: _FakeService(db), + ) + + listing = await _runner()._handle_rooms_command(_event("/group 1 bots")) + detail = await _runner()._handle_rooms_command(_event("/group 1 bot 2")) + + assert "🤖 **Bots in Release room**" in listing + assert "🧭 **Controls**" in listing + assert detail.startswith("🤖 **Operations**") + assert "Message this Bot: `/group 1 send @ops `" in detail + + +@pytest.mark.asyncio +async def test_native_group_picker_hides_unexpected_callback_details( + tmp_path, + monkeypatch, +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + + def fail_detail(*_args, **_kwargs): + raise RuntimeError("private path: /opt/data/state.db") + + monkeypatch.setattr(hosted_room_messaging, "format_room_detail", fail_detail) + adapter = _PickerAdapter() + runner = _runner(platform=Platform.TELEGRAM) + runner.adapters[Platform.TELEGRAM] = adapter + runner._thread_metadata_for_source = lambda source, anchor=None: {} + runner._reply_anchor_for_event = lambda event: None + + await runner._handle_rooms_command(_event("/group", platform=Platform.TELEGRAM)) + result = await adapter.calls[0]["on_choice_selected"]( + "chat-telegram", + adapter.calls[0]["choices"][0]["value"], + ) + + assert result == "Couldn’t load that Group Chat. Run `/group` again." + assert "/opt/data" not in result + + +@pytest.mark.asyncio +async def test_group_list_pages_keep_every_stable_number_reachable(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + for index in range(3, 11): + hosted_rooms.create_room( + db, + room_id=f"room-{index}", + name=f"Room {index}", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": f"bot-{index}", "handle": f"bot-{index}"}, + ], + authority_gateway_id="install:test-gateway", + ) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: _FakeService(db), + ) + + first = await _runner()._handle_rooms_command(_event("/group list")) + second = await _runner()._handle_rooms_command(_event("/group list 2")) + + assert "page 1 of 2" in first + assert "More: `/group list 2`" in first + assert "page 2 of 2" in second + assert "9. Room 9" in second + assert "10. Room 10" in second + + +@pytest.mark.asyncio +async def test_mutating_room_commands_require_the_stable_number(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_room_command( + _event("/group stop Release room") + ) + + assert result == ( + "Use `/group send `, `/group retry`, or " + "`/group stop`." + ) + assert service.stopped == [] + + +@pytest.mark.asyncio +async def test_retry_requeues_only_retryable_hosted_tasks(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + service.room_status = { + "running": True, + "working": False, + "blocked": True, + "pending_actions": [ + {"kind": "retry", "task_id": "task-1"}, + {"kind": "approval", "task_id": "task-2"}, + ], + } + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + event = _event("/group 1 retry", message_id="retry-1") + result = await _runner()._handle_room_command(event) + replay = await _runner()._handle_room_command(event) + + assert result.startswith("Retry queued for Release room (1 task).") + assert replay == result + assert service.retried == [ + ( + "release-room", + "task-1", + hosted_room_controls.control_retry_attempt_id( + f"retry:{messaging_event_id(event)}", + "task-1", + ), + ) + ] + + +@pytest.mark.asyncio +async def test_retry_is_durably_handed_to_the_active_worker_process( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + service.room_status = { + "running": True, + "working": False, + "blocked": True, + "pending_actions": [{"kind": "retry", "task_id": "task-1"}], + } + + def lease_held(_room_id, *, task_id, retry_id=None): + assert task_id == "task-1" + assert retry_id == hosted_room_controls.control_retry_attempt_id( + f"retry:{messaging_event_id(event)}", + task_id, + ) + raise hosted_room_driver.LeaseHeldError( + "room driver lease is held by another generation" + ) + + service.retry_room_task = lease_held + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group 1 retry", message_id="retry-cross-process") + + result = await _runner()._handle_room_command(event) + replay = await _runner()._handle_room_command(event) + + assert result.startswith("Retry queued for Release room (1 task).") + assert replay == result + pending = hosted_room_controls.load_pending_control_retries( + db, + room_id="release-room", + ) + assert len(pending) == 1 + assert pending[0].task_ids == ("task-1",) + assert pending[0].command_id.startswith("worker:retry:") + + +def test_room_list_and_detail_are_bounded_and_user_facing(tmp_path): + db, release, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + hosted_rooms.append_event( + db, + room_id=release["room_id"], + event_id="user-1", + kind="message.user", + actor={"kind": "user", "id": "messaging:signal:abc", "display_name": "Signal"}, + authority_gateway_id="install:test-gateway", + authority_epoch=1, + payload={"text": "Please inspect the release", "thread_id": "thread-1"}, + ) + hosted_rooms.append_event( + db, + room_id=release["room_id"], + event_id="member-1", + kind="message.member", + actor={"kind": "member", "id": "ops"}, + payload={ + "member_id": "ops", + "text": "The release is ready", + "thread_id": "thread-1", + "task_id": "task-1", + "turn_id": "turn-1", + "round_index": 0, + }, + authority_gateway_id="install:test-gateway", + authority_epoch=1, + ) + + listing = format_room_list(service) + assert "👥 **Group Chats**" in listing + assert "🟡 **1. Release room** · waiting for its Bots · 2 Bots" in listing + assert "🧭 **Controls**\nCheck: `/group `" in listing + assert "Send: `/group send `" in listing + assert "Retry: `/group retry`" in listing + assert "Stop: `/group stop`" in listing + detail = format_room_detail(service, release) + assert "**Signal:** Please inspect the release" in detail + assert "**Operations:** The release is ready" in detail + assert "🤖 **Bots**" in detail + assert "• Operations (`@ops`)" in detail + assert "Send: `/group 1 send `" in detail + assert "\n\n────────\n🧭 **Controls**\nSend:" in detail + assert "Retry:" not in detail + assert "Stop:" not in detail + assert "Message one Bot: `/group 1 send @handle `" in detail + + +def test_room_picker_choices_are_bounded_stable_and_user_facing(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + rooms = list_messaging_rooms(service) + + choices = room_picker_choices(service, rooms) + + assert all(choice["value"].startswith("room-") for choice in choices) + assert len({choice["value"] for choice in choices}) == 2 + assert {choice["label"] for choice in choices} == { + "🟢 1. Release room (2)", + "🟢 2. Research room (2)", + } + assert all(choice["full_width"] is True for choice in choices) + assert all("release-room" not in choice["label"] for choice in choices) + replacement = {**rooms[0], "room_id": "replacement-room", "messaging_ref": 1} + with pytest.raises(RoomControlError, match="no longer available"): + resolve_room_picker_choice([replacement], choices[0]["value"]) + + +def test_group_bot_picker_and_details_expose_only_useful_controls(tmp_path): + db, release, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + + choices = room_bot_picker_choices(service, release) + listing = format_room_bot_list(service, release) + detail = format_room_bot_detail(service, release, "@ops") + + assert all(choice["value"].startswith("p=") for choice in choices) + assert len({choice["value"] for choice in choices}) == 2 + assert [choice["label"] for choice in choices] == [ + "🤖 hermes · hermes", + "🤖 Operations · ops", + ] + assert "🤖 **Bots in Release room**" in listing + assert "2. **Operations** · `@ops`" in listing + assert "Bot details: `/group 1 bot `" in listing + assert detail.startswith("🤖 **Operations**") + assert "Message this Bot: `/group 1 send @ops `" in detail + assert "Stop" not in detail + with pytest.raises(RoomControlError, match="No Bot"): + format_room_bot_detail(service, release, "9") + + +def test_room_picker_keeps_recent_rooms_reachable_when_roster_is_large(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + latest = None + for index in range(3, 12): + latest = hosted_rooms.create_room( + db, + room_id=f"room-{index}", + name=f"Room {index}", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": f"bot-{index}", "handle": f"bot-{index}"}, + ], + authority_gateway_id="install:test-gateway", + ) + assert latest is not None + hosted_rooms.append_event( + db, + room_id=latest["room_id"], + event_id="latest-user-message", + kind="message.user", + actor={"kind": "user", "id": "messaging:test", "display_name": "Owner"}, + authority_gateway_id="install:test-gateway", + authority_epoch=1, + payload={"text": "Newest work", "thread_id": "thread-latest"}, + ) + service = _FakeService(db) + + choices = room_picker_choices(service, list_messaging_rooms(service)) + + assert len(choices) == 8 + assert str(latest["name"]) in choices[0]["label"] + + +def test_group_detail_escapes_untrusted_markup(tmp_path): + db = tmp_path / "state.db" + room = hosted_rooms.create_room( + db, + room_id="markup-room", + name="**Admin**", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": "ops", "handle": "ops", "display_name": "*System*"}, + ], + authority_gateway_id="install:test-gateway", + ) + hosted_rooms.append_event( + db, + room_id=room["room_id"], + event_id="markup-message", + kind="message.member", + actor={"kind": "member", "id": "ops"}, + authority_gateway_id="install:test-gateway", + authority_epoch=1, + payload={ + "member_id": "ops", + "text": "*not a command* `deploy_a` > {prod} @ops", + "thread_id": "thread-markup", + }, + ) + + detail = format_room_detail(_FakeService(db), room) + room_choices = room_picker_choices( + _FakeService(db), + list_messaging_rooms(_FakeService(db)), + ) + bot_choices = room_bot_picker_choices(_FakeService(db), room) + + assert "💬 **Admin**" in detail + assert "• System (`@ops`)" in detail + safe_preview = "*not a command* `deploy_a` > {prod} @ops" + assert safe_preview in detail + assert room_choices[0]["label"] == "🟢 1. Admin (2)" + assert bot_choices[1]["label"] == "🤖 System · ops" + unsafe_room = {**room, "name": "@room [Docs](https://invalid.example)"} + unsafe_choice = room_picker_choices(_FakeService(db), [unsafe_room])[0] + assert "@room" not in unsafe_choice["label"] + assert "[" not in unsafe_choice["label"] + + from gateway.platforms.signal_format import markdown_to_signal + from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin + from plugins.platforms.slack.adapter import SlackAdapter + from plugins.platforms.telegram.adapter import TelegramAdapter + + telegram = object.__new__(TelegramAdapter).format_message(detail) + whatsapp_adapter = object.__new__(WhatsAppBehaviorMixin) + whatsapp_adapter._sanitize_outbound_text = lambda text: text + whatsapp = whatsapp_adapter.format_message(detail) + signal, _styles = markdown_to_signal(detail) + slack = object.__new__(SlackAdapter).format_message(detail) + for rendered in (telegram, whatsapp, signal, slack): + assert "**Admin**" not in rendered + assert r"\*\*Admin" not in rendered + assert safe_preview in rendered + + +def test_bot_controls_preserve_valid_colon_handles_without_collision(tmp_path): + db = tmp_path / "state.db" + long_handle = "a" * 100 + room = hosted_rooms.create_room( + db, + room_id="colon-room", + name="Operations", + members=[ + {"member_id": "prod", "handle": "ops:prod", "display_name": "Prod"}, + {"member_id": "plain", "handle": "opsprod", "display_name": "Plain"}, + {"member_id": "numeric", "handle": "1", "display_name": "Numeric"}, + {"member_id": "long", "handle": long_handle, "display_name": "Long"}, + ], + authority_gateway_id="install:test-gateway", + ) + service = _FakeService(db) + + choices = room_bot_picker_choices(service, room) + detail = format_room_bot_detail(service, room, "ops:prod") + numeric_handle = format_room_bot_detail(service, room, "@1") + first_index = format_room_bot_detail(service, room, "1") + long_detail = format_room_bot_detail(service, room, long_handle) + + assert all(choice["value"].startswith("p=") for choice in choices) + assert len({choice["value"] for choice in choices}) == 4 + assert any("ops:prod" in choice["label"] for choice in choices) + assert "Handle: `@ops:prod`" in detail + assert "send @ops:prod " in detail + assert numeric_handle.startswith("🤖 **Numeric**") + assert first_index.startswith("🤖 **Prod**") + assert f"Handle: `@{long_handle}`" in long_detail + + collision_room = hosted_rooms.create_room( + db, + room_id="collision-room", + name="Collision check", + members=[ + {"member_id": "a:b", "handle": "c", "display_name": "Same"}, + {"member_id": "a", "handle": "b:c", "display_name": "Same"}, + ], + authority_gateway_id="install:test-gateway", + ) + collision_choices = room_bot_picker_choices(service, collision_room) + assert len({choice["value"] for choice in collision_choices}) == 2 + + +def test_duplicate_bot_picker_tokens_fail_closed(tmp_path, monkeypatch): + db, release, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + hosted_room_messaging, + "_room_member_picker_value", + lambda *_args: "p=duplicate", + ) + + with pytest.raises(RoomControlError, match="No Bot"): + format_room_bot_detail(service, release, "p=duplicate") + + +def test_group_detail_never_invents_a_missing_bot_handle(tmp_path): + room = { + "room_id": "classic-room", + "name": "Planning", + "members": [{"name": "CEO Assistant"}, {"name": "Review Bot"}], + "messaging_ref": 1, + "_room_mode": "desktop", + "desktop_available": True, + "log": [], + } + + detail = format_room_detail(_FakeService(tmp_path / "state.db"), room) + + assert "• CEO Assistant" in detail + assert "@CEOAssistant" not in detail + assert "Message one Bot:" not in detail + choices = room_bot_picker_choices( + _FakeService(tmp_path / "state.db"), + room, + ) + token = choices[1]["value"] + reordered = {**room, "members": list(reversed(room["members"]))} + selected = format_room_bot_detail( + _FakeService(tmp_path / "state.db"), + reordered, + token, + ) + assert selected.startswith("🤖 **Review Bot**") + + +def test_group_detail_only_offers_actions_that_match_current_state(tmp_path): + db, release, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + + idle = format_room_detail(service, release) + assert "Send: `/group 1 send `" in idle + assert "Retry:" not in idle + assert "Stop:" not in idle + + service.room_status = {"running": True, "working": True, "blocked": False} + working = format_room_detail(service, release) + assert "Stop: `/group 1 stop`" in working + assert "Retry:" not in working + + service.room_status = {"running": True, "working": False, "blocked": True} + blocked = format_room_detail(service, release) + assert "Retry:" not in blocked + assert "Stop:" not in blocked + + service.room_status = { + "running": True, + "working": False, + "blocked": True, + "pending_actions": [{"kind": "retry", "task_id": "task-1"}], + } + retryable = format_room_detail(service, release) + assert "Retry: `/group 1 retry`" in retryable + + service.room_status = { + "running": True, + "working": True, + "blocked": True, + "counts": {"stopping": 1}, + } + stopping = format_room_detail(service, release) + assert "🟡 stopping" in stopping + assert "needs attention" not in stopping + assert "Stop:" not in stopping + + service.room_status = { + "running": False, + "working": False, + "blocked": False, + "peer_routes": [ + {"member_id": "remote", "status": "needs_reauthorization"} + ], + } + assert MessagingRoomBackend(db_path=db, service=service).status( + "release-room" + )["blocked"] is True + + classic = { + "room_id": "classic-room", + "name": "Desktop work", + "members": [{"name": "worker", "handle": "worker"}], + "messaging_ref": 3, + "_room_mode": "desktop", + "desktop_available": False, + "desktop_command": {"action": "send", "state": "pending"}, + "log": [], + } + pending = format_room_detail(service, classic) + assert "Stop: `/group 3 stop`" in pending + + +def test_empty_group_list_points_to_the_only_available_next_step(tmp_path): + listing = format_room_list(_FakeService(tmp_path / "state.db")) + + assert listing.startswith("👥 **No Group Chats yet**") + assert "Create one in Hermes Desktop first." in listing + assert "" not in listing diff --git a/tests/gateway/test_hosted_room_messaging_security.py b/tests/gateway/test_hosted_room_messaging_security.py new file mode 100644 index 0000000000000..b891280665f6f --- /dev/null +++ b/tests/gateway/test_hosted_room_messaging_security.py @@ -0,0 +1,969 @@ +"""Authorization, dispatch, and lifecycle tests for Group Chat messaging.""" + +from __future__ import annotations + +import hashlib +import sqlite3 +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway import hosted_room_driver, hosted_room_messaging, hosted_rooms +from gateway.config import HomeChannel, Platform, PlatformConfig +from gateway.hosted_room_messaging import ( + MessagingRoomBackend, + RoomControlError, + messaging_actor, + messaging_event_id, + relay_provenance_is_unknown, +) +from gateway.session import SessionSource +from hermes_cli.commands import resolve_command +from tests.gateway.test_hosted_room_messaging import ( + _FakeService, + _HoldingRPC, + _ImmediateRPC, + _TestHostedRoomService, + _event, + _runner, + _seed_rooms, + _wait_for, +) + + +def test_messaging_identity_is_stable_private_and_edit_safe(): + first = _event("/group 1 send hello", platform=Platform.TELEGRAM) + first.platform_update_id = 123 + second = _event("/group 1 send edited", platform=Platform.TELEGRAM) + second.platform_update_id = 124 + actor = messaging_actor(first, gateway_id="install:test-gateway") + assert actor["display_name"] == "Display Name via Telegram" + assert "user-1" not in actor["id"] + assert messaging_event_id(first) == messaging_event_id(second) + assert messaging_event_id(first) == messaging_event_id(first) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", [Platform.SLACK, Platform.MATRIX]) +async def test_dm_label_without_one_to_one_proof_cannot_control_group_chats( + tmp_path, monkeypatch, platform +): + service = _FakeService(tmp_path / "state.db") + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner(platform=platform)._handle_rooms_command( + _event("/group", platform=platform, is_one_to_one=None) + ) + + assert result == ( + "Group Chat controls are private. Use your authorized one-to-one " + "Hermes chat." + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "platform", + [Platform.WHATSAPP_CLOUD, Platform.EMAIL, Platform.SMS], +) +async def test_native_distinct_dm_proves_private_owner_surface(tmp_path, monkeypatch, platform): + service = _FakeService(tmp_path / "state.db") + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner( + platform=platform, + extra={"allow_admin_from": ["user-1"]}, + )._handle_rooms_command( + _event( + "/group list", + platform=platform, + is_one_to_one=None, + ) + ) + + assert result.startswith("👥 **No Group Chats yet**") + + +@pytest.mark.asyncio +async def test_edited_message_cannot_start_group_chat_work(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group 1 send changed", message_id="message-1") + event.source.message_is_edit = True + + result = await _runner()._handle_rooms_command(event) + + assert result == "Edited messages can’t run Group Chat commands. Send a new message." + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_mutating_group_chat_commands_have_a_per_sender_rate_limit( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + runner = _runner() + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + monkeypatch.setattr("gateway.slash_commands._GROUP_CHAT_MUTATION_RATE_LIMIT", 2) + + first = await runner._handle_rooms_command( + _event("/group 1 send first", message_id="rate-1") + ) + second = await runner._handle_rooms_command( + _event("/group 1 send second", message_id="rate-2") + ) + limited = await runner._handle_rooms_command( + _event("/group 1 send third", message_id="rate-3") + ) + + assert first.startswith("Queued in") + assert second.startswith("Queued in") + assert limited == "Too many Group Chat commands. Wait a moment and try again." + assert len(service.sent) == 2 + + +@pytest.mark.parametrize( + "raw_message", + [ + {"timestamp_ms": 1770000000000}, + {"trigger_id": "slack-trigger-1"}, + SimpleNamespace(id="discord-interaction-1"), + ], +) +def test_real_channel_raw_ids_are_stable_without_normalized_message_id(raw_message): + event = _event("/group 1 send hello") + event.message_id = None + event.source.message_id = None + event.raw_message = raw_message + assert messaging_event_id(event) == messaging_event_id(event) + + +def test_mutation_fails_closed_without_a_transport_redelivery_id(): + event = _event("/group 1 send hello") + event.message_id = None + event.source.message_id = None + with pytest.raises(RoomControlError, match="stable message ID"): + messaging_event_id(event) + + +def test_signal_group_idempotency_includes_sender_identity(): + first = _event("/group 1 send hello", user_id="sender-a") + second = _event("/group 1 send hello", user_id="sender-b") + for event in (first, second): + event.message_id = None + event.source.message_id = None + event.raw_message = {"timestamp_ms": 1770000000000} + assert messaging_event_id(first) != messaging_event_id(second) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", [p for p in Platform if p is not Platform.LOCAL]) +async def test_send_handler_is_shared_by_every_gateway_channel( + tmp_path, monkeypatch, platform +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event( + "/group 1 send hello from messaging", + platform=platform, + message_id=f"message-{platform.value}", + ) + runner = _runner(platform=platform) + result = await runner._handle_room_command(event) + rooms_command = f"{runner._typed_command_prefix_for(platform)}group" + assert result == f"Queued in Release room. Check: `{rooms_command} 1`." + assert service.sent[-1]["payload"]["text"] == "hello from messaging" + platform_label = platform.value.replace("_", " ").title() + assert service.sent[-1]["actor"]["display_name"] == ( + f"Display Name via {platform_label}" + ) + + +@pytest.mark.asyncio +async def test_entity_first_group_send_is_dispatched(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_rooms_command( + _event("/group 1 send hello from Signal", message_id="entity-first-1") + ) + + assert result == "Queued in Release room. Check: `/group 1`." + assert service.sent[-1]["payload"]["text"] == "hello from Signal" + + +@pytest.mark.asyncio +async def test_send_rejects_attachments_instead_of_silently_dropping_them( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + result = await _runner()._handle_room_command( + _event("/group 1 send inspect this", media=True) + ) + assert "Attachments from messaging chats aren’t supported yet" in result + assert service.sent == [] + + ignored = _event("/group 1 send inspect this") + ignored.source.message_had_attachments = True + result = await _runner()._handle_room_command(ignored) + assert "Attachments from messaging chats aren’t supported yet" in result + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_bot_authored_controls_are_rejected_to_prevent_bridge_loops( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + result = await _runner(platform=Platform.DISCORD)._handle_room_command( + _event( + "/group 1 send repeat this", + platform=Platform.DISCORD, + is_bot=True, + ) + ) + assert result == "Group Chat controls are only available to people." + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_raw_webhook_bot_marker_is_rejected_even_without_source_flag( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group 1 send repeat this") + event.raw_message = {"subtype": "bot_message", "bot_id": "B123"} + result = await _runner()._handle_rooms_command(event) + assert result == "Group Chat controls are only available to people." + assert service.sent == [] + + +def test_relay_and_session_roundtrip_preserve_bot_provenance(): + from gateway.relay.ws_transport import _event_from_wire + + source = SessionSource( + platform=Platform.DISCORD, + chat_id="chat-1", + user_id="bot-1", + is_bot=True, + ) + assert SessionSource.from_dict(source.to_dict()).is_bot is True + wire_source = source.to_dict() + wire_source["message_is_edit"] = False + relayed = _event_from_wire( + { + "text": "/group", + "message_type": "command", + "source": wire_source, + } + ) + assert relayed.source.is_bot is True + assert relay_provenance_is_unknown(relayed) is False + + +def test_legacy_relay_without_author_classification_fails_closed(): + from gateway.relay.ws_transport import _event_from_wire + + relayed = _event_from_wire( + { + "text": "/group", + "message_type": "command", + "source": { + "platform": "discord", + "chat_id": "chat-1", + "chat_type": "dm", + "user_id": "user-1", + }, + } + ) + assert relay_provenance_is_unknown(relayed) is True + + +@pytest.mark.parametrize( + ("platform", "verified", "expected"), + [ + ("signal", None, True), + ("telegram", None, True), + ("whatsapp", None, True), + ("slack", None, None), + ("slack", True, True), + ("matrix", False, False), + ], +) +def test_authenticated_relay_preserves_one_to_one_privacy_proof( + platform, + verified, + expected, +): + from gateway.relay.ws_transport import _event_from_wire + + source = { + "platform": platform, + "chat_id": "private-chat", + "chat_type": "dm", + "user_id": "user-1", + "is_bot": False, + "message_is_edit": False, + } + if verified is not None: + source["one_to_one_verified"] = verified + relayed = _event_from_wire( + { + "text": "/group", + "message_type": "command", + "source": source, + } + ) + + assert relayed.source.is_one_to_one is expected + assert relay_provenance_is_unknown(relayed) is False + + +@pytest.mark.asyncio +async def test_classified_relay_dm_with_explicit_admin_can_control_group_chats( + tmp_path, + monkeypatch, +): + from gateway.relay.ws_transport import _event_from_wire + + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + event = _event_from_wire( + { + "text": "/group list", + "message_type": "command", + "source": { + "platform": "signal", + "chat_id": "chat-signal", + "chat_type": "dm", + "user_id": "user-1", + "is_bot": False, + "message_is_edit": False, + }, + } + ) + + result = await _runner(extra={"allow_admin_from": ["user-1"]})._handle_rooms_command(event) + + assert result.startswith("👥 **Group Chats**") + + +@pytest.mark.asyncio +async def test_stop_requires_admin_when_operator_enabled_slash_gating( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + extra = { + "allow_admin_from": ["admin"], + "user_allowed_commands": ["groups"], + } + result = await _runner(extra=extra)._handle_room_command( + _event("/group 1 stop", user_id="member") + ) + assert result.startswith("This chat can’t control Group Chats") + assert service.stopped == [] + + result = await _runner(extra=extra)._handle_room_command( + _event("/group 1 stop", user_id="admin", message_id="stop-1") + ) + assert result == ( + "Stop requested for Release room. Active work will stop safely. " + "Check: `/group 1`." + ) + assert service.stopped[0][0] == "release-room" + + +@pytest.mark.asyncio +async def test_even_an_admin_cannot_expose_room_history_in_a_shared_chat( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group list", user_id="admin", chat_type="group") + + result = await _runner(extra={"group_allow_admin_from": ["admin"]})._handle_rooms_command(event) + + assert result == ( + "Group Chat controls are private. Use your authorized one-to-one " + "Hermes chat." + ) + assert "Release room" not in result + + +@pytest.mark.asyncio +async def test_room_history_and_mutation_require_an_explicit_admin( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={"allow_from": ["user-1"]}) + runner._is_user_authorized_for_source = lambda _source: True + denial = "This chat can’t control Group Chats" + assert denial in await runner._handle_rooms_command(_event("/group")) + assert denial in await runner._handle_room_command( + _event("/group 1 send hello") + ) + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_home_dm_controls_rooms_without_duplicate_admin_list( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "user-1") + runner = _runner(extra={}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + + listing = await runner._handle_rooms_command(_event("/group list")) + assert listing.startswith("👥 **Group Chats**") + result = await runner._handle_room_command( + _event("/group 1 send hello", message_id="home-send-1") + ) + assert result.startswith("Queued in Release room") + assert service.sent[0]["payload"]["text"] == "hello" + + +@pytest.mark.asyncio +async def test_explicit_home_group_allows_only_the_selecting_operator( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "user-1") + runner = _runner(extra={"allow_from": ["user-1"]}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home team", + user_id="user-1", + ) + event = _event( + "/group 1 send hello from home", + message_id="home-group-send-1", + chat_type="group", + is_one_to_one=False, + ) + + result = await runner._handle_room_command(event) + + assert result.startswith("Queued in Release room") + assert service.sent[0]["payload"]["text"] == "hello from home" + + +@pytest.mark.asyncio +async def test_non_home_dm_still_requires_explicit_admin(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={}) + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="different-chat", + name="Home", + ) + + result = await runner._handle_room_command( + _event("/group 1 send hello", message_id="other-send-1") + ) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_shared_home_dm_does_not_auto_promote_an_allowed_user( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={"allow_from": ["user-1", "user-2"]}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + + result = await runner._handle_room_command( + _event("/group 1 send hello", message_id="shared-send-1") + ) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("allowed_users", "allow_all"), + [ + ("user-1,user-2", ""), + ("user-1", "true"), + ], +) +async def test_builtin_platform_grants_fail_closed_without_registry( + tmp_path, + monkeypatch, + allowed_users, + allow_all, +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + monkeypatch.setenv("SIGNAL_ALLOWED_USERS", allowed_users) + monkeypatch.setenv("SIGNAL_ALLOW_ALL_USERS", allow_all) + runner = _runner(extra={}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + + result = await runner._handle_room_command( + _event("/group 1 send hello", message_id="grant-send-1") + ) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_routed_profile_uses_transport_principals_for_owner_census( + tmp_path, monkeypatch +): + from contextlib import contextmanager + + from gateway import authz_mixin, run as gateway_run + + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + active_scope = {"name": "routed"} + + @contextmanager + def fake_profile_scope(path): + previous = active_scope["name"] + active_scope["name"] = Path(path).name + try: + yield + finally: + active_scope["name"] = previous + + def fake_auth_env(name, default=""): + if name == "SIGNAL_ALLOWED_USERS": + return ( + "user-1,user-2" + if active_scope["name"] == "transport" + else "user-1" + ) + return default + + monkeypatch.setattr(gateway_run, "_profile_runtime_scope", fake_profile_scope) + monkeypatch.setattr(authz_mixin, "_auth_env", fake_auth_env) + runner = _runner(extra={}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + event = _event("/group 1 send hello", message_id="routed-send-1") + event.source.profile = "worker" + event.source._authorization_profile_home = Path("/transport") + + result = await runner._handle_room_command(event) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_secondary_adapter_allowlist_owns_the_owner_census( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={"allow_from": ["user-1"]}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + adapter = SimpleNamespace( + config=PlatformConfig(extra={"allow_from": ["user-1", "user-2"]}) + ) + runner._profile_adapters = {"worker": {Platform.SIGNAL: adapter}} + runner.adapters = {} + event = _event("/group 1 send hello", message_id="secondary-send-1") + event.source.profile = "worker" + event.source._transport_adapter_ref = lambda: adapter + + result = await runner._handle_room_command(event) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_registered_adapter_without_visible_allowlist_fails_closed( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={"allow_from": ["user-1"]}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + adapter = SimpleNamespace(config=PlatformConfig(extra={})) + runner._profile_adapters = {"worker": {Platform.SIGNAL: adapter}} + runner.adapters = {} + event = _event("/group 1 send hello", message_id="opaque-send-1") + event.source.profile = "worker" + event.source._transport_adapter_ref = lambda: adapter + + result = await runner._handle_room_command(event) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_relayed_home_dm_requires_explicit_admin(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={"allow_from": ["user-1"]}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + event = _event("/group 1 send hello", message_id="relay-send-1") + event.source.delivered_via_upstream_relay = True + event.metadata = { + "relay_author_classified": True, + "relay_edit_classified": True, + } + + result = await runner._handle_room_command(event) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_busy_dispatch_runs_room_control_without_touching_main_agent(): + runner = _runner() + runner._handle_rooms_command = AsyncMock(return_value="room-dispatched") + event = _event("/group 1 send hello") + result = await runner._dispatch_busy_slash_command( + event, + resolve_command("group"), + "session-key", + event.source, + ) + assert result == "room-dispatched" + runner._handle_rooms_command.assert_awaited_once_with(event) + + +@pytest.mark.asyncio +async def test_real_service_persists_server_owned_messaging_actor(tmp_path, monkeypatch): + service = _TestHostedRoomService(tmp_path / "state.db") + service.create_room( + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: MessagingRoomBackend(db_path=service.db_path, service=service), + ) + event = _event( + "/group 1 send @ops inspect the release", + platform=Platform.SIGNAL, + ) + result = await _runner()._handle_room_command(event) + assert result == "Queued in Release room. Check: `/group 1`." + delta = hosted_rooms.read_events( + service.db_path, + room_id="release-room", + since_seq=0, + limit=20, + ) + user_event = next(row for row in delta["events"] if row["kind"] == "message.user") + assert user_event["actor"]["display_name"] == "Display Name via Signal" + assert user_event["actor"]["id"].startswith("messaging:signal:") + assert "user-1" not in user_event["actor"]["id"] + + +def test_cross_process_store_wakes_owner_without_desktop_transport(tmp_path): + service = _TestHostedRoomService(tmp_path / "state.db") + service.rpc = _ImmediateRPC() + service.runtime.rpc = service.rpc + service.create_room( + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + messaging_process = MessagingRoomBackend(db_path=service.db_path) + first_event = _event( + "/group 1 send @ops inspect the release", + platform=Platform.WHATSAPP, + message_id="message-1", + ) + second_event = _event( + "/group 1 send @ops check the notes", + platform=Platform.WHATSAPP, + message_id="message-2", + ) + for event, text in ( + (first_event, "@ops inspect the release"), + (second_event, "@ops check the notes"), + ): + event_id = messaging_event_id(event) + messaging_process.send( + room_id="release-room", + event_id=event_id, + payload={"text": text, "thread_id": event_id}, + actor=messaging_actor(event, gateway_id="install:test-gateway"), + ) + service.start() + try: + _wait_for( + lambda: sum( + row["kind"] == "message.member" + for row in hosted_rooms.read_events( + service.db_path, + room_id="release-room", + since_seq=0, + limit=40, + )["events"] + ) + == 2 + ) + finally: + assert service.stop(timeout=1.0) + + events = hosted_rooms.read_events( + service.db_path, + room_id="release-room", + since_seq=0, + limit=40, + )["events"] + assert [row["kind"] for row in events[:2]] == ["message.user", "message.user"] + assert sum(row["kind"] == "message.member" for row in events) == 2 + assert events[0]["actor"]["display_name"] == "Display Name via Whatsapp" + + +def test_cross_process_stop_cancels_durable_queued_work(tmp_path): + service = _TestHostedRoomService(tmp_path / "state.db") + service.create_room( + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + service.send( + room_id="release-room", + event_id="user-1", + payload={"text": "@ops inspect", "thread_id": "thread-1"}, + ) + messaging_process = MessagingRoomBackend(db_path=service.db_path) + assert messaging_process.stop_room("release-room", cancel_id="stop-1") == 1 + assert messaging_process.stop_room("release-room", cancel_id="stop-1") == 1 + service.prepare_room(service.bindings()[0]) + assert messaging_process.status("release-room")["working"] is False + tasks = hosted_room_driver.list_tasks(service.db_path, room_id="release-room") + assert [task["status"] for task in tasks] == ["cancelled"] + + +def test_cross_process_stop_cancels_deferred_work(tmp_path): + service = _TestHostedRoomService(tmp_path / "state.db") + service.create_room( + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + service.send( + room_id="release-room", + event_id="user-1", + payload={"text": "@ops inspect", "thread_id": "thread-1"}, + ) + with sqlite3.connect(service.db_path) as conn: + conn.execute( + """UPDATE hosted_room_driver_tasks + SET status='deferred', execution_generation=1 + WHERE room_id='release-room'""" + ) + conn.commit() + + messaging_process = MessagingRoomBackend(db_path=service.db_path) + assert messaging_process.stop_room("release-room", cancel_id="stop-deferred") == 1 + task = hosted_room_driver.list_tasks(service.db_path, room_id="release-room")[0] + assert task["status"] == "stopping" + assert task["cancel_id"] == "stop-deferred" + + +def test_cross_process_send_is_idempotent_on_transport_redelivery( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + hosted_rooms, + "local_authority_gateway_id", + lambda: "install:test-gateway", + ) + db, room, _ = _seed_rooms(tmp_path) + messaging_process = MessagingRoomBackend(db_path=db) + event = _event("/group 1 send hello", platform=Platform.TELEGRAM) + event_id = messaging_event_id(event) + payload = {"text": "hello", "thread_id": event_id} + actor = messaging_actor(event, gateway_id="install:test-gateway") + first = messaging_process.send( + room_id=room["room_id"], + event_id=event_id, + payload=payload, + actor=actor, + ) + second = messaging_process.send( + room_id=room["room_id"], + event_id=event_id, + payload=payload, + actor=actor, + ) + assert first["seq"] == second["seq"] == 1 + assert second["idempotent"] is True + delta = hosted_rooms.read_events( + db, room_id=room["room_id"], since_seq=0, limit=20 + ) + assert len(delta["events"]) == 1 + + +def test_cross_process_stop_is_acknowledged_by_owner_for_running_work(tmp_path): + service = _TestHostedRoomService(tmp_path / "state.db") + service.rpc = _HoldingRPC() + service.runtime.rpc = service.rpc + service.create_room( + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + messaging_process = MessagingRoomBackend(db_path=service.db_path) + event = _event("/group 1 send inspect") + messaging_process.send( + room_id="release-room", + event_id=messaging_event_id(event), + payload={"text": "inspect", "thread_id": messaging_event_id(event)}, + actor=messaging_actor(event, gateway_id="install:test-gateway"), + ) + service.start() + try: + _wait_for( + lambda: any( + task["status"] == "running" + for task in hosted_room_driver.list_tasks( + service.db_path, room_id="release-room" + ) + ) + ) + assert messaging_process.stop_room("release-room", cancel_id="stop-1") == 1 + _wait_for( + lambda: hosted_room_driver.list_tasks( + service.db_path, room_id="release-room" + )[0]["status"] + == "cancelled" + ) + finally: + assert service.stop(timeout=1.0) + + assert len(service.rpc.interrupts) == 1 + tasks = hosted_room_driver.list_tasks(service.db_path, room_id="release-room") + assert len(tasks) == 1 + assert tasks[0]["status"] == "cancelled" diff --git a/tests/gateway/test_hosted_room_peer.py b/tests/gateway/test_hosted_room_peer.py index 49698c4cc406b..f48da59900d73 100644 --- a/tests/gateway/test_hosted_room_peer.py +++ b/tests/gateway/test_hosted_room_peer.py @@ -5,6 +5,7 @@ import hashlib import json import stat +import sys from concurrent.futures import ThreadPoolExecutor from pathlib import Path @@ -19,6 +20,7 @@ PROTOCOL_VERSION, RoomLinkProbe, catalog_mapping, + decode_room_grant, derive_room_grant_secret, gateway_room_grant_secret, issue_room_grant, @@ -51,7 +53,8 @@ def test_gateway_room_grant_secret_is_private_persistent_and_not_an_api_key( secret_path = home / ".room-link-grant-secret" assert first == second assert len(first) == 32 - assert stat.S_IMODE(secret_path.stat().st_mode) == 0o600 + if not sys.platform.startswith("win"): + assert stat.S_IMODE(secret_path.stat().st_mode) == 0o600 assert secret_path.read_bytes() != first assert first != derive_room_grant_secret("gateway-api-key-1234567890") @@ -291,6 +294,64 @@ def test_room_grant_fails_closed_for_tamper_expiry_and_permission(): with pytest.raises(HostedRoomGrantError, match="signature"): verify_room_grant(SECRET, token[:-1] + "A", dispatch, now=105) + assert ( + decode_room_grant( + SECRET, + token, + permission="status", + now=100 + 30 * 24 * 60 * 60, + allow_expired_for_revocation=True, + )["grant_id"] + == "grant-1" + ) + with pytest.raises(HostedRoomGrantError, match="only.*revocation"): + decode_room_grant( + SECRET, + token, + permission="dispatch", + now=100 + 30 * 24 * 60 * 60, + allow_expired_for_revocation=True, + ) + + +def test_expired_status_grant_can_only_authenticate_idempotent_revocation(): + dispatch = _dispatch() + token = issue_room_grant( + SECRET, + grant_id="grant-expired", + room_id=dispatch.room_id, + home_install_id=dispatch.home_install_id, + authority_gateway_id=dispatch.authority_gateway_id, + authority_epoch=dispatch.authority_epoch, + member_id=dispatch.member_id, + target_install_id=dispatch.target_install_id, + target_profile=dispatch.target_profile, + execution_policy_digest=dispatch.execution_policy_digest, + permissions=("status",), + issued_at=100, + ttl_seconds=10, + status_expires_at=120, + ) + + with pytest.raises(HostedRoomGrantError, match="expired"): + decode_room_grant(SECRET, token, permission="status", now=121) + claims = decode_room_grant( + SECRET, + token, + permission="status", + now=121, + allow_expired_for_revocation=True, + ) + assert claims["grant_id"] == "grant-expired" + with pytest.raises(HostedRoomGrantError, match="only.*revocation"): + decode_room_grant( + SECRET, + token, + permission="run", + now=121, + allow_expired_for_revocation=True, + ) + def test_link_selection_prefers_safe_direct_then_overlay_then_relay_then_pull(): selected = select_room_link( diff --git a/tests/gateway/test_hosted_room_replicas.py b/tests/gateway/test_hosted_room_replicas.py index cfe550168378d..e2021356b2c9c 100644 --- a/tests/gateway/test_hosted_room_replicas.py +++ b/tests/gateway/test_hosted_room_replicas.py @@ -1,7 +1,7 @@ -"""Tests for gateway/hosted_room_replicas.py — replica ingest, promotion, and -stale-authority demotion for hosted Group Chat rooms.""" +"""Tests for passive hosted Group Chat room replicas.""" import json +import sqlite3 import pytest @@ -73,6 +73,203 @@ def test_ingest_page_is_idempotent(tmp_path): assert again["stored_seq"] == 3 +def test_passive_replica_reserves_room_id_against_local_create(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + db = _replica_db(tmp_path) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + with pytest.raises(rooms.RoomConflictError, match="passive replica"): + rooms.create_room( + db, + room_id="room-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + + +def test_database_guard_blocks_an_old_process_promoting_a_replica(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + db = _replica_db(tmp_path) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + with sqlite3.connect(db) as conn, pytest.raises( + sqlite3.IntegrityError, match="already reserved" + ): + conn.execute( + """INSERT INTO hosted_rooms + (room_id, name, members_json, authority_gateway_id, + authority_epoch, next_seq, event_bytes, revision, + created_at, updated_at, disbanded_at) + VALUES ('room-1', 'Field Room', ?, ?, 2, 4, 0, 1, 2, 2, NULL)""", + (json.dumps(MEMBERS, separators=(",", ":")), AUTH_B), + ) + + +def test_disbanded_replica_room_id_cannot_be_recreated(tmp_path): + authority_db = _authority_db(tmp_path) + _seed_room(authority_db, n_events=1) + rooms.disband_room( + authority_db, + room_id="room-1", + expected_gateway_id=AUTH_A, + expected_epoch=1, + ) + page = rooms.read_events( + authority_db, room_id="room-1", include_disbanded=True + ) + db = _replica_db(tmp_path) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + with pytest.raises(rooms.RoomConflictError, match="passive replica"): + rooms.create_room( + db, + room_id="room-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + + +def test_replica_ingest_rejects_existing_authoritative_room(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + db = _replica_db(tmp_path) + rooms.create_room( + db, + room_id="room-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + with pytest.raises(replicas.ReplicaError, match="locally authoritative"): + replicas.ingest_page( + db, + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) + + +@pytest.mark.parametrize( + ("kind", "payload", "reason"), + [ + ( + "authority.claimed", + { + "authority_gateway_id": AUTH_B, + "authority_epoch": 1, + "previous_gateway_id": AUTH_A, + "promoted_from_replica": True, + }, + "unsafe_replica_promotion", + ), + ( + "authority.lost", + { + "authority_gateway_id": AUTH_B, + "authority_epoch": 1, + "previous_gateway_id": AUTH_A, + }, + "unsafe_authority_demotion", + ), + ], +) +def test_migration_quarantines_unsafe_takeover_lineage( + tmp_path, kind, payload, reason +): + db = _replica_db(tmp_path) + rooms.create_room( + db, + room_id="room-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + rooms.append_event( + db, + room_id="room-1", + event_id=f"unsafe-{kind}", + kind=kind, + actor={"kind": "system", "id": "authority-control"}, + payload=payload, + authority_gateway_id=AUTH_B, + authority_epoch=1, + ) + with sqlite3.connect(db) as conn: + conn.execute("DROP TABLE hosted_room_quarantine") + + with pytest.raises(rooms.RoomQuarantinedError, match="read-only"): + rooms.room_state(db, room_id="room-1") + listed = rooms.list_rooms(db) + assert listed[0]["safety_status"] == "authority_quarantined" + assert listed[0]["safety_reason"] == reason + with pytest.raises(rooms.RoomQuarantinedError): + rooms.append_event( + db, + room_id="room-1", + event_id="blocked", + kind="message.user", + actor=USER, + payload={"text": "must not commit"}, + authority_gateway_id=AUTH_B, + authority_epoch=1, + ) + + +def test_database_guard_quarantines_a_late_old_process_demotion(tmp_path): + db = _replica_db(tmp_path) + rooms.create_room( + db, + room_id="room-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_A, + ) + actor = json.dumps( + {"kind": "system", "id": "authority-control"}, + separators=(",", ":"), + sort_keys=True, + ) + payload = json.dumps( + { + "previous_gateway_id": AUTH_A, + "authority_gateway_id": AUTH_B, + "authority_epoch": 2, + }, + separators=(",", ":"), + sort_keys=True, + ) + with sqlite3.connect(db) as conn: + conn.execute( + """INSERT INTO hosted_room_events + (room_id, seq, event_id, kind, actor_json, authority_epoch, + payload_json, created_at) + VALUES ('room-1', 1, 'old-demotion', 'authority.lost', ?, 2, ?, 2)""", + (actor, payload), + ) + conn.execute( + """UPDATE hosted_rooms + SET authority_gateway_id=?, authority_epoch=2, next_seq=2 + WHERE room_id='room-1'""", + (AUTH_B,), + ) + with pytest.raises(sqlite3.IntegrityError, match="quarantined"): + conn.execute( + """INSERT INTO hosted_room_events + (room_id, seq, event_id, kind, actor_json, authority_epoch, + payload_json, created_at) + VALUES ('room-1', 2, 'late-write', 'message.user', ?, 2, + '{"text":"unsafe"}', 3)""", + (json.dumps(USER, separators=(",", ":"), sort_keys=True),), + ) + with pytest.raises(rooms.RoomQuarantinedError): + rooms.room_state(db, room_id="room-1") + + def test_ingest_rejects_sequence_gap(tmp_path): adb = _authority_db(tmp_path) _seed_room(adb, n_events=5) @@ -88,30 +285,28 @@ def test_ingest_rejects_sequence_gap(tmp_path): ) -def test_ingest_rejects_epoch_regression(tmp_path): +def test_ingest_rejects_conflicting_overlap(tmp_path): page = _seed_room(_authority_db(tmp_path)) rdb = _replica_db(tmp_path) - newer = json.loads(json.dumps(page)) - newer["authority"]["epoch"] = 3 replicas.ingest_page( - rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=newer + rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page ) - stale = json.loads(json.dumps(page)) - stale["authority"]["epoch"] = 2 - with pytest.raises(replicas.ReplicaEpochRegressionError): + conflicting = json.loads(json.dumps(page)) + conflicting["events"][0]["payload"]["text"] = "rewritten" + with pytest.raises(replicas.ReplicaError, match="conflicts"): replicas.ingest_page( rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, - page=stale, + page=conflicting, ) -def test_ingest_requires_authority_stamp(tmp_path): +def test_ingest_rejects_duplicate_event_ids_in_one_page(tmp_path): page = _seed_room(_authority_db(tmp_path)) - page.pop("authority") - with pytest.raises(replicas.ReplicaError): + page["events"][1]["event_id"] = page["events"][0]["event_id"] + with pytest.raises(replicas.ReplicaError, match="repeats an event_id"): replicas.ingest_page( _replica_db(tmp_path), room_id="room-1", @@ -121,176 +316,616 @@ def test_ingest_requires_authority_stamp(tmp_path): ) -def test_promote_replica_continues_room_at_next_epoch(tmp_path, monkeypatch): +def test_ingest_rejects_same_epoch_gateway_substitution(tmp_path): page = _seed_room(_authority_db(tmp_path)) rdb = _replica_db(tmp_path) replicas.ingest_page( rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page ) - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_B) + substituted = json.loads(json.dumps(page)) + substituted["authority"]["gateway_id"] = AUTH_B + with pytest.raises( + replicas.ReplicaLineageUnverifiedError, match="authority" + ) as raised: + replicas.ingest_page( + rdb, + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=substituted, + ) + assert raised.value.reason == "replica_lineage_unverified" - promoted = replicas.promote_replica(rdb, room_id="room-1") - assert promoted["authority_gateway_id"] == AUTH_B - assert promoted["authority_epoch"] == 2 - assert promoted["previous_gateway_id"] == AUTH_A - assert promoted["claim_seq"] == 4 - # The room is now locally authoritative with the full history + claim. - replay = rooms.read_events(rdb, room_id="room-1", since_seq=0, limit=100) - assert [e["seq"] for e in replay["events"]] == [1, 2, 3, 4] - claim = replay["events"][-1] - assert claim["kind"] == "authority.claimed" - assert claim["payload"]["previous_gateway_id"] == AUTH_A - assert claim["payload"]["authority_epoch"] == 2 - assert replay["authority"] == {"gateway_id": AUTH_B, "epoch": 2} +def test_ingest_rejects_epoch_jump_without_claim(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + jumped = json.loads(json.dumps(page)) + jumped["authority"] = {"gateway_id": AUTH_B, "epoch": 3} + with pytest.raises(replicas.ReplicaError, match="lineage"): + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=jumped, + ) - # New work continues under the new epoch. - rooms.append_event( - rdb, + +def test_fresh_replica_reports_unverified_later_epoch_lineage(tmp_path): + authority_db = _authority_db(tmp_path) + _seed_room(authority_db, n_events=1) + rooms.claim_authority( + authority_db, room_id="room-1", - event_id="post-takeover", - kind="message.user", - actor=USER, - payload={"text": "continuing"}, - authority_gateway_id=AUTH_B, - authority_epoch=2, + expected_gateway_id=AUTH_A, + expected_epoch=1, + new_gateway_id=AUTH_B, + event_id="claim-b", ) + page = rooms.read_events(authority_db, room_id="room-1", limit=100) - # The old authority's identity/epoch is fenced out. - with pytest.raises(rooms.HostedRoomError): - rooms.append_event( + with pytest.raises( + replicas.ReplicaLineageUnverifiedError, match="first authority epoch" + ) as raised: + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) + assert raised.value.reason == "replica_lineage_unverified" + + +def test_ingest_rejects_latest_seq_regression(tmp_path): + page = _seed_room(_authority_db(tmp_path), n_events=4) + rdb = _replica_db(tmp_path) + replicas.ingest_page( + rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + stale = json.loads(json.dumps(page)) + stale["latest_seq"] = 2 + stale["cursor"] = 2 + stale["events"] = stale["events"][:2] + stale["has_more"] = False + with pytest.raises(replicas.ReplicaError, match="regress"): + replicas.ingest_page( rdb, room_id="room-1", - event_id="stale-write", - kind="message.user", - actor=USER, - payload={"text": "stale"}, - authority_gateway_id=AUTH_A, - authority_epoch=1, + room_name="Field Room", + members=MEMBERS, + page=stale, + ) + + +def test_ingest_rejects_inconsistent_page_cursor(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + page["cursor"] -= 1 + with pytest.raises(replicas.ReplicaError, match="cursor"): + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, ) - # Replica bookkeeping is consumed by promotion. + +def test_ingest_rejects_non_verbatim_oversized_page(tmp_path): + page = _seed_room(_authority_db(tmp_path), n_events=1) + template = page["events"][0] + page["events"] = [ + { + **template, + "seq": index, + "event_id": f"event-{index}", + } + for index in range(1, rooms.MAX_LOG_LIMIT + 2) + ] + page["cursor"] = len(page["events"]) + page["latest_seq"] = len(page["events"]) + with pytest.raises(replicas.ReplicaError, match="cannot exceed"): + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) + + +def test_ingest_requires_authority_stamp(tmp_path): + page = _seed_room(_authority_db(tmp_path)) + page.pop("authority") with pytest.raises(replicas.ReplicaError): - replicas.replica_state(rdb, room_id="room-1") + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) -def test_promote_refuses_when_room_exists_locally(tmp_path, monkeypatch): - db = _authority_db(tmp_path) - page = _seed_room(db) - # Same DB also holds a replica row for the same id — conflict must win. - replicas.ingest_page( - db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page +def test_partial_replica_remains_passive_and_reports_coverage(tmp_path): + adb = _authority_db(tmp_path) + _seed_room(adb, n_events=5) + partial = rooms.read_events(adb, room_id="room-1", since_seq=0, limit=2) + rdb = _replica_db(tmp_path) + result = replicas.ingest_page( + rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=partial ) - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_B) - with pytest.raises(rooms.RoomConflictError): - replicas.promote_replica(db, room_id="room-1") + assert result["caught_up"] is False + state = replicas.replica_state(rdb, room_id="room-1") + assert state["last_seq"] == 2 + assert state["latest_seq"] == 5 + assert not hasattr(replicas, "promote_replica") + assert not hasattr(replicas, "demote_room") -def test_promote_refuses_when_already_authority(tmp_path, monkeypatch): - page = _seed_room(_authority_db(tmp_path)) +def test_disbanded_room_replica_keeps_terminal_state(tmp_path): + adb = _authority_db(tmp_path) + _seed_room(adb, n_events=1) + rooms.disband_room( + adb, + room_id="room-1", + expected_gateway_id=AUTH_A, + expected_epoch=1, + ) + page = rooms.read_events( + adb, + room_id="room-1", + since_seq=0, + limit=100, + include_disbanded=True, + ) rdb = _replica_db(tmp_path) replicas.ingest_page( rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page ) - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_A) - with pytest.raises(replicas.ReplicaError): - replicas.promote_replica(rdb, room_id="room-1") + assert replicas.replica_state(rdb, room_id="room-1")["disbanded_at"] is not None -def test_demote_fences_stale_local_authority(tmp_path, monkeypatch): - adb = _authority_db(tmp_path) - _seed_room(adb) - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_A) +def test_ingest_rejects_terminal_event_before_source_history_is_complete(tmp_path): + page = _terminal_page(tmp_path, room_id="room-1") + page["latest_seq"] += 1 + page["has_more"] = True + with pytest.raises(replicas.ReplicaError, match="complete the source history"): + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) - result = replicas.demote_room( - adb, room_id="room-1", observed_gateway_id=AUTH_B, observed_epoch=2 - ) - assert result["idempotent"] is False - assert result["authority_gateway_id"] == AUTH_B - assert result["authority_epoch"] == 2 - replay = rooms.read_events(adb, room_id="room-1", since_seq=0, limit=100) - lost = replay["events"][-1] - assert lost["kind"] == "authority.lost" - assert lost["payload"]["authority_gateway_id"] == AUTH_B - assert replay["authority"] == {"gateway_id": AUTH_B, "epoch": 2} +def test_replica_storage_shares_the_gateway_event_budget(tmp_path, monkeypatch): + page = _seed_room(_authority_db(tmp_path)) + monkeypatch.setattr(replicas, "MAX_REPLICA_EVENT_BYTES", 0) + with pytest.raises(replicas.ReplicaError, match="storage exhausted"): + replicas.ingest_page( + _replica_db(tmp_path), + room_id="room-1", + room_name="Field Room", + members=MEMBERS, + page=page, + ) + - # Local sends at the stale identity/epoch are now rejected. - with pytest.raises(rooms.HostedRoomError): +def test_authoritative_append_also_counts_replica_bytes(tmp_path, monkeypatch): + page = _seed_room(_authority_db(tmp_path)) + db = _replica_db(tmp_path) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + rooms.create_room( + db, + room_id="local-room", + name="Local", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + with sqlite3.connect(db) as conn: + replica_bytes = int( + conn.execute( + "SELECT SUM(event_bytes) FROM hosted_room_replicas" + ).fetchone()[0] + ) + monkeypatch.setattr(rooms, "MAX_GATEWAY_EVENT_BYTES", replica_bytes) + with pytest.raises(rooms.HostedRoomError, match="storage is full"): rooms.append_event( - adb, - room_id="room-1", - event_id="after-demote", + db, + room_id="local-room", + event_id="would-overflow", kind="message.user", actor=USER, - payload={"text": "stale"}, - authority_gateway_id=AUTH_A, + payload={"text": "one byte too many"}, + authority_gateway_id=AUTH_B, authority_epoch=1, ) - # Repeating the same observation is idempotent. - again = replicas.demote_room( - adb, room_id="room-1", observed_gateway_id=AUTH_B, observed_epoch=2 + +def test_replica_budget_matches_the_authoritative_store(): + assert replicas.MAX_REPLICA_EVENT_BYTES == rooms.MAX_GATEWAY_EVENT_BYTES + + +def _terminal_page(tmp_path, *, room_id: str, now: float = 0): + authority_db = tmp_path / f"authority-{room_id}.db" + _seed_room(authority_db, n_events=1, room_id=room_id) + rooms.disband_room( + authority_db, + room_id=room_id, + expected_gateway_id=AUTH_A, + expected_epoch=1, + now=now, + ) + return rooms.read_events( + authority_db, + room_id=room_id, + include_disbanded=True, ) - assert again["idempotent"] is True -def test_demote_rejects_non_superseding_epoch(tmp_path, monkeypatch): - adb = _authority_db(tmp_path) - _seed_room(adb) - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_A) - with pytest.raises(replicas.ReplicaEpochRegressionError): - replicas.demote_room( - adb, room_id="room-1", observed_gateway_id=AUTH_B, observed_epoch=1 +def test_aged_terminal_replicas_free_room_slots_but_keep_ids( + tmp_path, monkeypatch +): + db = _replica_db(tmp_path) + monkeypatch.setattr(replicas, "MAX_REPLICA_ROOMS", 2) + monkeypatch.setattr(rooms, "DISBANDED_REPLICA_RETENTION_SECONDS", 1) + for room_id in ("old-1", "old-2"): + replicas.ingest_page( + db, + room_id=room_id, + room_name="Field Room", + members=MEMBERS, + page=_terminal_page(tmp_path, room_id=room_id), + now=0, ) + fresh_page = _seed_room( + tmp_path / "authority-fresh.db", room_id="fresh", n_events=1 + ) + replicas.ingest_page( + db, + room_id="fresh", + room_name="Field Room", + members=MEMBERS, + page=fresh_page, + now=2, + ) + with pytest.raises( + replicas.ReplicaHistoryExpiredError, match="history expired" + ): + replicas.replica_state(db, room_id="old-1") + with pytest.raises(rooms.RoomConflictError, match="retired passive replica"): + rooms.create_room( + db, + room_id="old-1", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) -def test_full_failover_round_trip(tmp_path, monkeypatch): - """Authority A hosts, replica B follows, A dies, B promotes, A returns - and is fenced + demoted; the room's history survives intact throughout.""" - adb = _authority_db(tmp_path) - rdb = _replica_db(tmp_path) - page = _seed_room(adb, n_events=4) + +def test_fresh_terminal_replicas_yield_slots_under_count_pressure( + tmp_path, monkeypatch +): + db = _replica_db(tmp_path) + monkeypatch.setattr(replicas, "MAX_REPLICA_ROOMS", 2) + for room_id in ("recent-1", "recent-2"): + replicas.ingest_page( + db, + room_id=room_id, + room_name="Field Room", + members=MEMBERS, + page=_terminal_page(tmp_path, room_id=room_id, now=10), + now=10, + ) + fresh_page = _seed_room( + tmp_path / "authority-current.db", room_id="current", n_events=1 + ) replicas.ingest_page( - rdb, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + db, + room_id="current", + room_name="Field Room", + members=MEMBERS, + page=fresh_page, + now=10, ) + with pytest.raises(replicas.ReplicaHistoryExpiredError): + replicas.replica_state(db, room_id="recent-1") + with sqlite3.connect(db) as conn, pytest.raises( + sqlite3.IntegrityError, match="already reserved" + ): + conn.execute( + """INSERT INTO hosted_rooms + (room_id, name, members_json, authority_gateway_id, + authority_epoch, next_seq, event_bytes, revision, + created_at, updated_at, disbanded_at) + VALUES ('recent-1', 'Field Room', ?, ?, 2, 1, 0, 1, 3, 3, NULL)""", + (json.dumps(MEMBERS, separators=(",", ":")), AUTH_B), + ) - # A "dies"; B takes over. - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_B) - promoted = replicas.promote_replica(rdb, room_id="room-1") - rooms.append_event( - rdb, - room_id="room-1", - event_id="b-work", + +def test_authoritative_append_reclaims_terminal_replica_bytes( + tmp_path, monkeypatch +): + db = _replica_db(tmp_path) + replicas.ingest_page( + db, + room_id="old", + room_name="Field Room", + members=MEMBERS, + page=_terminal_page(tmp_path, room_id="old"), + now=0, + ) + with sqlite3.connect(db) as conn: + replica_bytes = int( + conn.execute( + "SELECT SUM(event_bytes) FROM hosted_room_replicas" + ).fetchone()[0] + ) + rooms.create_room( + db, + room_id="local-room", + name="Local", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) + monkeypatch.setattr(rooms, "MAX_GATEWAY_EVENT_BYTES", replica_bytes) + event = rooms.append_event( + db, + room_id="local-room", + event_id="after-pressure", kind="message.user", actor=USER, - payload={"text": "work continues on B"}, + payload={"text": "still writable"}, authority_gateway_id=AUTH_B, - authority_epoch=promoted["authority_epoch"], + authority_epoch=1, ) + assert event["seq"] == 1 + with pytest.raises(replicas.ReplicaHistoryExpiredError): + replicas.replica_state(db, room_id="old") + with pytest.raises(rooms.RoomConflictError, match="retired passive replica"): + rooms.create_room( + db, + room_id="old", + name="Field Room", + members=MEMBERS, + authority_gateway_id=AUTH_B, + ) - # A comes back, observes B's claim, and fences itself. - monkeypatch.setattr(replicas, "local_authority_gateway_id", lambda: AUTH_A) - replicas.demote_room( - adb, + +def test_pre_terminal_state_replica_schema_migrates_in_place(tmp_path): + db = _replica_db(tmp_path) + with sqlite3.connect(db) as conn: + conn.execute( + """CREATE TABLE hosted_room_replicas ( + room_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + members_json TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + last_seq INTEGER NOT NULL, + latest_seq INTEGER NOT NULL, + event_bytes INTEGER NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + conn.execute( + """INSERT INTO hosted_room_replicas VALUES + ('room-1', 'Field Room', ?, ?, 1, 0, 0, 0, 1.0, 1.0)""", + (json.dumps(MEMBERS, separators=(",", ":")), AUTH_A), + ) + state = replicas.replica_state(db, room_id="room-1") + assert state["disbanded_at"] is None + + +def test_schema_migration_recovers_existing_disband_tombstone(tmp_path): + authority_db = _authority_db(tmp_path) + _seed_room(authority_db, n_events=1) + rooms.disband_room( + authority_db, room_id="room-1", - observed_gateway_id=AUTH_B, - observed_epoch=promoted["authority_epoch"], + expected_gateway_id=AUTH_A, + expected_epoch=1, + now=42, ) - with pytest.raises(rooms.HostedRoomError): - rooms.append_event( - adb, - room_id="room-1", - event_id="a-stale", - kind="message.user", - actor=USER, - payload={"text": "split brain attempt"}, + page = rooms.read_events( + authority_db, + room_id="room-1", + include_disbanded=True, + ) + db = _replica_db(tmp_path) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + with sqlite3.connect(db) as conn: + conn.execute("UPDATE hosted_room_replicas SET disbanded_at=NULL") + assert replicas.replica_state(db, room_id="room-1")["disbanded_at"] == 42 + + +def test_schema_migration_quarantines_post_tombstone_history(tmp_path): + db = _replica_db(tmp_path) + actor = json.dumps(USER, separators=(",", ":"), sort_keys=True) + system_actor = json.dumps( + {"kind": "system", "id": "room-control"}, + separators=(",", ":"), + sort_keys=True, + ) + with sqlite3.connect(db) as conn: + conn.execute( + """CREATE TABLE hosted_room_replicas ( + room_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + members_json TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + last_seq INTEGER NOT NULL, + latest_seq INTEGER NOT NULL, + event_bytes INTEGER NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + conn.execute( + """CREATE TABLE hosted_room_replica_events ( + room_id TEXT NOT NULL, + seq INTEGER NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + actor_json TEXT NOT NULL, + authority_epoch INTEGER, + payload_json TEXT NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (room_id, seq) + )""" + ) + conn.execute( + """INSERT INTO hosted_room_replicas VALUES + ('room-1', 'Field Room', ?, ?, 1, 2, 2, 0, 1.0, 2.0)""", + (json.dumps(MEMBERS, separators=(",", ":")), AUTH_A), + ) + conn.executemany( + """INSERT INTO hosted_room_replica_events VALUES + ('room-1', ?, ?, ?, ?, 1, ?, ?)""", + [ + (1, "disband", "room.disbanded", system_actor, "{}", 1.0), + (2, "later", "message.user", actor, '{"text":"later"}', 2.0), + ], + ) + state = replicas.replica_state(db, room_id="room-1") + assert state["safety_status"] == "quarantined" + assert state["safety_reason"] == "events_after_disband" + assert state["event_bytes"] > 0 + with sqlite3.connect(db) as conn: + conn.row_factory = sqlite3.Row + assert rooms._prune_disbanded_replicas_locked( # noqa: SLF001 + conn, + now=None, + max_replica_event_bytes=0, + max_replica_rooms=0, + ) == 0 + assert replicas.replica_state(db, room_id="room-1")["safety_status"] == ( + "quarantined" + ) + + +def test_each_replica_transaction_audits_late_old_process_writes(tmp_path): + db = _replica_db(tmp_path) + page = _seed_room(_authority_db(tmp_path), n_events=2) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + + with sqlite3.connect(db) as conn: + original = conn.execute( + """SELECT event_id, kind, actor_json, authority_epoch, + payload_json, created_at + FROM hosted_room_replica_events + WHERE room_id='room-1' AND seq=1""" + ).fetchone() + conn.execute( + """INSERT INTO hosted_room_replica_events( + room_id, seq, event_id, kind, actor_json, authority_epoch, + payload_json, created_at + ) VALUES ('room-1', 3, ?, ?, ?, ?, ?, ?)""", + original, + ) + conn.execute( + """UPDATE hosted_room_replicas + SET last_seq=3, latest_seq=3 + WHERE room_id='room-1'""" + ) + + state = replicas.replica_state(db, room_id="room-1") + assert state["safety_status"] == "quarantined" + assert state["safety_reason"] == "duplicate_event_id" + + +def test_late_old_process_gateway_actor_must_match_replica_authority(tmp_path): + db = _replica_db(tmp_path) + page = _seed_room(_authority_db(tmp_path), n_events=2) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + + with sqlite3.connect(db) as conn: + conn.execute( + """INSERT INTO hosted_room_replica_events( + room_id, seq, event_id, kind, actor_json, authority_epoch, + payload_json, created_at + ) VALUES ('room-1', 3, 'old-gateway-write', 'room.activity', + ?, 1, '{}', 3.0)""", + ( + json.dumps( + {"kind": "gateway", "id": AUTH_B}, + sort_keys=True, + separators=(",", ":"), + ), + ), + ) + conn.execute( + """UPDATE hosted_room_replicas + SET last_seq=3, latest_seq=3 + WHERE room_id='room-1'""" + ) + + state = replicas.replica_state(db, room_id="room-1") + assert state["safety_status"] == "quarantined" + assert state["safety_reason"] == "gateway_actor_authority_mismatch" + + +def test_late_old_process_cannot_assert_a_later_epoch_without_proof(tmp_path): + db = _replica_db(tmp_path) + page = _seed_room(_authority_db(tmp_path), n_events=2) + replicas.ingest_page( + db, room_id="room-1", room_name="Field Room", members=MEMBERS, page=page + ) + + with sqlite3.connect(db) as conn: + conn.execute( + "UPDATE hosted_room_replicas SET authority_epoch=2 WHERE room_id='room-1'" + ) + conn.execute( + """UPDATE hosted_room_replica_events + SET authority_epoch=2 WHERE room_id='room-1'""" + ) + + state = replicas.replica_state(db, room_id="room-1") + assert state["safety_status"] == "quarantined" + assert state["safety_reason"] == "unverified_authority_epoch" + + +def test_sharded_store_preserves_public_limit_overrides(tmp_path, monkeypatch): + db = _replica_db(tmp_path) + rooms.create_room( + db, + room_id="room-1", + name="Room", + members=MEMBERS, + authority_gateway_id=AUTH_A, + ) + + monkeypatch.setattr(rooms, "MAX_ROOM_NAME_CHARS", 3) + with pytest.raises(rooms.HostedRoomError, match="invalid room name"): + rooms.create_room( + db, + room_id="room-2", + name="Long name", + members=MEMBERS, authority_gateway_id=AUTH_A, - authority_epoch=1, ) - # B's room holds the complete history: 4 original + claim + new work. - replay = rooms.read_events(rdb, room_id="room-1", since_seq=0, limit=100) - kinds = [e["kind"] for e in replay["events"]] - assert kinds == ["message.user"] * 4 + ["authority.claimed", "message.user"] - assert replay["authority"]["gateway_id"] == AUTH_B + monkeypatch.setattr(rooms, "MAX_EVENTS_PER_ROOM", 0) + monkeypatch.setattr(rooms, "CONTROL_EVENT_COUNT_RESERVE", 0) + monkeypatch.setattr(rooms, "CONTROL_EVENT_BYTE_RESERVE", 0) + with pytest.raises(rooms.HostedRoomError, match="history limit"): + rooms.disband_room( + db, + room_id="room-1", + expected_gateway_id=AUTH_A, + expected_epoch=1, + ) diff --git a/tests/gateway/test_hosted_rooms.py b/tests/gateway/test_hosted_rooms.py index 1c252a8b1b73b..cc5d96cb8664b 100644 --- a/tests/gateway/test_hosted_rooms.py +++ b/tests/gateway/test_hosted_rooms.py @@ -742,11 +742,17 @@ def test_room_log_pages_are_bounded_by_serialized_event_bytes(tmp_path, monkeypa payload={"text": "x" * 180, "index": index}, ) - one_event = rooms.read_events(db, room_id="room-1", limit=1) - budget = len( - json.dumps(one_event, ensure_ascii=False, separators=(",", ":")).encode( - "utf-8" + single_event_pages = [ + rooms.read_events(db, room_id="room-1", since_seq=cursor, limit=1) + for cursor in range(4) + ] + budget = max( + len( + json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) ) + for page in single_event_pages ) + 1 monkeypatch.setattr(rooms, "MAX_LOG_PAGE_BYTES", budget) @@ -1107,6 +1113,66 @@ def read_then_prune(*args, **kwargs): assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0 +def test_terminal_retry_reconstructs_from_compacted_thread_transcript(tmp_path): + db = tmp_path / "state.db" + _create(db) + _append( + db, + room_id="room-1", + event_id="user-retry", + kind="message.user", + actor=USER, + payload={"text": "retry me", "thread_id": "thread-1"}, + now=11, + ) + _append( + db, + room_id="room-1", + event_id="deferred-1", + kind="turn.deferred", + actor={"kind": "gateway", "id": "gateway-a"}, + payload={ + "discussion_event_id": "user-retry", + "execution_generation": 1, + "member_id": "bot-1", + "seen_through_seq": 1, + "task_id": "task-retry", + "thread_id": "thread-1", + }, + authority_gateway_id="gateway-a", + authority_epoch=1, + now=12, + ) + _append( + db, + room_id="room-1", + event_id="activity-1", + kind="room.activity", + actor={"kind": "gateway", "id": "gateway-a"}, + payload={ + "discussion_event_id": "user-retry", + "reason_code": "silent_round", + "status": "settled", + "thread_id": "thread-1", + }, + authority_gateway_id="gateway-a", + authority_epoch=1, + now=13, + ) + checkpoint = HostedRoomPolicyCheckpoint(db) + checkpoint.sync(room_id="room-1", latest_seq=3) + + with sqlite3.connect(db) as conn: + assert conn.execute( + """SELECT COUNT(*) FROM hosted_room_policy_events + WHERE room_id='room-1'""" + ).fetchone()[0] == 0 + events = checkpoint.events_for_task(room_id="room-1", source_event_seq=1) + assert [(event["seq"], event["kind"]) for event in events] == [ + (1, "message.user") + ] + + def test_pre_actor_draft_database_migrates_with_explicit_legacy_identity(tmp_path): db = tmp_path / "state.db" _create_pre_actor_database(db) diff --git a/tests/gateway/test_session_lease_wait_refresh.py b/tests/gateway/test_session_lease_wait_refresh.py new file mode 100644 index 0000000000000..30619d19c4a11 --- /dev/null +++ b/tests/gateway/test_session_lease_wait_refresh.py @@ -0,0 +1,107 @@ +"""Cross-process session lease-wait refresh must not flood chat gateways (#89166). + +While a durable session's turn lease is held by another Hermes process, +``run_agent._on_session_turn_lease_wait`` emits an initial wait notice once +and then a periodic "Still waiting ... (Ns)" refresh roughly every 15s. The +refresh only makes sense on surfaces that can update the initial notice in +place: on adapters without ``send_or_update_status`` (WeCom, Weixin, QQ, +Signal, ... — all SUPPORTS_MESSAGE_EDITING = False) the status path falls +back to a plain send, and a two-minute wait produced eight standalone +chat messages that drowned the eventual delivery. + +``_should_suppress_lease_wait_refresh`` gates the refresh by adapter +capability; the initial notice and the lease-timeout warning use different +wording and must always be delivered. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from gateway.config import Platform +from gateway.run import ( + _prepare_gateway_status_message, + _should_suppress_lease_wait_refresh, +) +from run_agent import SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE + +INITIAL_WAIT_NOTICE = ( + "⏳ Another Hermes process is using this session; " + "waiting for it to finish before starting your turn..." +) +LEASE_TIMEOUT_WARNING = ( + "⏳ Another Hermes process kept this session busy too " + "long. Your message was not processed - wait for the " + "other process to finish, then send it again." +) + + +def _adapter_without_status_update(): + # Shape of the WeCom/Weixin/QQ/Signal adapters: plain send, no + # send_or_update_status, SUPPORTS_MESSAGE_EDITING = False. + return SimpleNamespace(send=AsyncMock(), SUPPORTS_MESSAGE_EDITING=False) + + +def _adapter_with_status_update(): + # Shape of the Telegram/Slack adapters: refreshes edit the same bubble. + return SimpleNamespace( + send=AsyncMock(), + send_or_update_status=AsyncMock(), + SUPPORTS_MESSAGE_EDITING=True, + ) + + +def _prepared_refresh(elapsed_seconds: int) -> str: + message = SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE.format( + elapsed_seconds=elapsed_seconds + ) + prepared = _prepare_gateway_status_message( + Platform.WECOM, "lifecycle", message + ) + assert prepared is not None, "refresh must survive the noise filter first" + return prepared + + +def test_refresh_suppressed_on_adapter_without_in_place_updates(): + adapter = _adapter_without_status_update() + for elapsed in (15, 30, 105, 120): + assert ( + _should_suppress_lease_wait_refresh( + adapter, _prepared_refresh(elapsed) + ) + is True + ) + + +def test_refresh_delivered_when_adapter_updates_in_place(): + adapter = _adapter_with_status_update() + assert ( + _should_suppress_lease_wait_refresh(adapter, _prepared_refresh(15)) + is False + ) + + +def test_initial_wait_notice_never_suppressed(): + adapter = _adapter_without_status_update() + prepared = _prepare_gateway_status_message( + Platform.WECOM, "lifecycle", INITIAL_WAIT_NOTICE + ) + assert prepared is not None + assert _should_suppress_lease_wait_refresh(adapter, prepared) is False + + +def test_lease_timeout_warning_never_suppressed(): + adapter = _adapter_without_status_update() + prepared = _prepare_gateway_status_message( + Platform.WECOM, "lifecycle", LEASE_TIMEOUT_WARNING + ) + assert prepared is not None + assert _should_suppress_lease_wait_refresh(adapter, prepared) is False + + +def test_unrelated_lifecycle_status_never_suppressed(): + adapter = _adapter_without_status_update() + prepared = _prepare_gateway_status_message( + Platform.WECOM, "lifecycle", "Resumed session after gateway restart" + ) + assert prepared is not None + assert _should_suppress_lease_wait_refresh(adapter, prepared) is False diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index 078d787d43cf9..d7a269a89473d 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -588,8 +588,31 @@ async def _fake_handle_message(event): adapter._fetch_attachment = AsyncMock(return_value=(fetch_path, fetch_ext)) await adapter._handle_envelope(envelope) assert dispatched, "_handle_envelope did not dispatch any event" + assert dispatched[0].source.is_one_to_one is True return dispatched[0] + @pytest.mark.asyncio + async def test_signal_edit_is_marked_for_command_replay_guard(self, monkeypatch): + envelope = _make_dm_envelope( + sender="+15559876543", + text="/group 1 send changed", + attachments=[], + ) + data_message = envelope["envelope"].pop("dataMessage") + envelope["envelope"]["editMessage"] = {"dataMessage": data_message} + adapter = _make_signal_adapter(monkeypatch) + adapter._rpc, _ = _stub_rpc(None) + dispatched = [] + + async def _fake_handle_message(event): + dispatched.append(event) + + adapter.handle_message = _fake_handle_message + await adapter._handle_envelope(envelope) + + assert len(dispatched) == 1 + assert dispatched[0].source.message_is_edit is True + @pytest.mark.asyncio async def test_pdf_attachment_sets_document_type(self, monkeypatch): """A PDF attachment (application/pdf) must produce MessageType.DOCUMENT, not TEXT.""" @@ -994,6 +1017,9 @@ async def fake_handle(event): assert event.reply_to_text == "want to grab lunch?" assert event.reply_to_author_id == "other-author" assert event.reply_to_is_own_message is False + from gateway.hosted_room_messaging import messaging_event_id + + assert messaging_event_id(event) == messaging_event_id(event) @pytest.mark.asyncio diff --git a/tests/gateway/test_signal_format.py b/tests/gateway/test_signal_format.py index 0b8805e2e0ab0..4f20b92f1cd9e 100644 --- a/tests/gateway/test_signal_format.py +++ b/tests/gateway/test_signal_format.py @@ -187,7 +187,6 @@ def test_lone_asterisk(self): # Should not crash; any italic match would be a false positive assert "5" in text and "15" in text - # =========================================================================== # signal-markdown-strip-patch: core conversion pipeline # =========================================================================== @@ -255,4 +254,3 @@ def test_signal_does_not_support_editing(self, monkeypatch): monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") from gateway.platforms.signal import SignalAdapter assert SignalAdapter.SUPPORTS_MESSAGE_EDITING is False - diff --git a/tests/gateway/test_slash_access.py b/tests/gateway/test_slash_access.py index 82a7ad2d90c8f..e6df0978bdd0d 100644 --- a/tests/gateway/test_slash_access.py +++ b/tests/gateway/test_slash_access.py @@ -5,9 +5,11 @@ """ from __future__ import annotations -from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig from gateway.session import SessionSource from gateway.slash_access import ( + is_home_control_source, + is_home_dm_source, policy_for_source, policy_from_extra, ) @@ -126,3 +128,92 @@ def test_no_admin_list_for_dm_means_unrestricted_in_dm(self): assert grp_p.enabled is True assert grp_p.can_run("999", "stop") is False # gated + +class TestHomeDmSource: + def _config(self, *, user_id=None, scope_id=None): + return GatewayConfig( + platforms={ + Platform.TELEGRAM: PlatformConfig( + enabled=True, + home_channel=HomeChannel( + platform=Platform.TELEGRAM, + chat_id="home-chat", + name="Home", + user_id=user_id, + scope_id=scope_id, + ), + ) + } + ) + + def test_legacy_home_dm_matches_authenticated_sender(self): + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="dm", + user_id="owner", + ) + assert is_home_dm_source(self._config(), source) is True + + def test_home_group_never_inherits_owner_controls(self): + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="group", + user_id="owner", + ) + assert is_home_dm_source(self._config(), source) is False + + def test_home_group_matches_only_its_stored_operator(self): + config = self._config(user_id="owner") + owner = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="group", + user_id="owner", + ) + other = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="group", + user_id="other", + ) + assert is_home_control_source(config, owner) is True + assert is_home_control_source(config, other) is False + + def test_stored_identity_and_scope_must_match(self): + config = self._config(user_id="owner", scope_id="tenant-a") + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="dm", + user_id="other", + scope_id="tenant-a", + ) + assert is_home_dm_source(config, source) is False + wrong_scope = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="dm", + user_id="owner", + scope_id="tenant-b", + ) + assert is_home_dm_source(config, wrong_scope) is False + + def test_bot_and_other_dm_do_not_match(self): + config = self._config() + bot = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="dm", + user_id="owner", + is_bot=True, + ) + other = SessionSource( + platform=Platform.TELEGRAM, + chat_id="other-chat", + chat_type="dm", + user_id="owner", + ) + assert is_home_dm_source(config, bot) is False + assert is_home_dm_source(config, other) is False diff --git a/tests/gateway/test_telegram_choice_picker.py b/tests/gateway/test_telegram_choice_picker.py new file mode 100644 index 0000000000000..29eba712565e4 --- /dev/null +++ b/tests/gateway/test_telegram_choice_picker.py @@ -0,0 +1,143 @@ +"""Telegram finite-choice pickers stay bound to one user and message.""" + +from __future__ import annotations + +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from plugins.platforms.telegram.adapter import TelegramAdapter + + +def _adapter(state): + adapter = object.__new__(TelegramAdapter) + adapter._choice_picker_state = {"chat-1": state} + adapter._is_callback_user_authorized = lambda *_args, **_kwargs: True + adapter.format_message = lambda text: text + return adapter + + +def _query(*, message_id=10, user_id="user-1"): + return SimpleNamespace( + answer=AsyncMock(), + edit_message_text=AsyncMock(), + from_user=SimpleNamespace(id=user_id, first_name="Owner"), + message=SimpleNamespace( + chat=SimpleNamespace(type="private"), + chat_id="chat-1", + message_id=message_id, + message_thread_id=None, + ), + ) + + +def _state(callback): + return { + "choices": [{"value": "12", "label": "Product launch"}], + "expires_at": time.monotonic() + 120, + "msg_id": 10, + "on_choice_selected": callback, + "requester_user_id": "user-1", + "session_key": "session-1", + } + + +@pytest.mark.asyncio +async def test_full_width_choices_render_one_room_per_row(monkeypatch): + from plugins.platforms.telegram import adapter as telegram_adapter + + monkeypatch.setattr( + telegram_adapter, + "InlineKeyboardButton", + lambda label, callback_data: {"label": label, "callback_data": callback_data}, + ) + monkeypatch.setattr( + telegram_adapter, + "InlineKeyboardMarkup", + lambda rows: SimpleNamespace(inline_keyboard=rows), + ) + adapter = object.__new__(TelegramAdapter) + adapter._bot = object() + adapter._choice_picker_state = {} + adapter._link_preview_kwargs = lambda: {} + adapter._reply_to_message_id_for_send = lambda *_args, **_kwargs: None + adapter._reply_to_mode = "none" + adapter._send_message_with_thread_fallback = AsyncMock( + return_value=SimpleNamespace(message_id=10) + ) + adapter._thread_kwargs_for_send = lambda *_args, **_kwargs: {} + adapter.format_message = lambda text: text + + result = await adapter.send_choice_picker( + chat_id="chat-1", + title="Group Chats", + choices=[ + {"value": "1", "label": "Release", "full_width": True}, + {"value": "2", "label": "Research", "full_width": True}, + ], + session_key="session-1", + on_choice_selected=AsyncMock(), + metadata={"requester_user_id": "user-1"}, + ) + + assert result.success is True + markup = adapter._send_message_with_thread_fallback.await_args.kwargs[ + "reply_markup" + ] + assert [len(row) for row in markup.inline_keyboard] == [1, 1] + assert adapter._choice_picker_state["chat-1"]["requester_user_id"] == "user-1" + + +@pytest.mark.asyncio +async def test_stale_keyboard_cannot_select_a_newer_picker(): + callback = AsyncMock(return_value="opened") + adapter = _adapter(_state(callback)) + query = _query(message_id=9) + + await adapter._handle_choice_picker_callback(query, "cp:0", "chat-1") + + callback.assert_not_awaited() + query.edit_message_text.assert_not_awaited() + assert "chat-1" in adapter._choice_picker_state + assert "expired" in query.answer.await_args.kwargs["text"] + + +@pytest.mark.asyncio +async def test_expired_picker_is_removed_without_running_callback(): + callback = AsyncMock(return_value="opened") + state = _state(callback) + state["expires_at"] = time.monotonic() - 1 + adapter = _adapter(state) + query = _query() + + await adapter._handle_choice_picker_callback(query, "cp:0", "chat-1") + + callback.assert_not_awaited() + assert "chat-1" not in adapter._choice_picker_state + + +@pytest.mark.asyncio +async def test_picker_rejects_another_authorized_user(): + callback = AsyncMock(return_value="opened") + adapter = _adapter(_state(callback)) + query = _query(user_id="user-2") + + await adapter._handle_choice_picker_callback(query, "cp:0", "chat-1") + + callback.assert_not_awaited() + assert "another user" in query.answer.await_args.kwargs["text"] + + +@pytest.mark.asyncio +async def test_current_owner_selection_edits_the_bound_message(): + callback = AsyncMock(return_value="💬 **Product launch**") + adapter = _adapter(_state(callback)) + query = _query() + + await adapter._handle_choice_picker_callback(query, "cp:0", "chat-1") + + callback.assert_awaited_once_with("chat-1", "12") + assert query.edit_message_text.await_args.kwargs["text"] == "💬 **Product launch**" + assert "chat-1" not in adapter._choice_picker_state diff --git a/tests/gateway/test_telegram_format.py b/tests/gateway/test_telegram_format.py index 2195179b28f20..82d8e6ac98e25 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -74,7 +74,6 @@ def test_plain_text_specials_escaped(self, adapter): assert "\\." in result assert "\\!" in result - # ========================================================================= # format_message - code blocks # ========================================================================= diff --git a/tests/gateway/test_telegram_reply_quote.py b/tests/gateway/test_telegram_reply_quote.py index 7150ed1dc8e43..37b4ceeac6be8 100644 --- a/tests/gateway/test_telegram_reply_quote.py +++ b/tests/gateway/test_telegram_reply_quote.py @@ -70,5 +70,18 @@ def test_native_partial_quote_used_as_reply_to_text(): assert event.reply_to_text == "Item B: rotate keys" assert event.reply_to_message_id == "42" + assert event.source.is_one_to_one is True + assert event.source.message_is_edit is False +def test_edited_telegram_message_is_marked_for_command_replay_guard(): + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + message = _make_message(text="/group 1 send changed") + message.edit_date = object() + + event = adapter._build_message_event(message, MessageType.TEXT) + + assert event.source.is_one_to_one is True + assert event.source.message_is_edit is True diff --git a/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py index 79e1dacb78e22..0d83abc427dc1 100644 --- a/tests/gateway/test_whatsapp_formatting.py +++ b/tests/gateway/test_whatsapp_formatting.py @@ -110,7 +110,6 @@ def test_already_whatsapp_italic(self): # Already-WhatsApp _italic_ passes through unchanged assert adapter.format_message("_italic_") == "_italic_" - # --------------------------------------------------------------------------- # MAX_MESSAGE_LENGTH tests # --------------------------------------------------------------------------- @@ -215,6 +214,13 @@ async def test_quoted_reply_metadata_is_preserved_in_raw_message(self): assert event.raw_message["quotedParticipant"] == "99999999999@s.whatsapp.net" assert event.raw_message["quotedRemoteJid"] == "15551234567@s.whatsapp.net" assert event.raw_message["hasQuotedMessage"] is True + assert event.source.is_one_to_one is True + + unknown = dict(data) + unknown.pop("isGroup") + unknown_event = await adapter._build_message_event(unknown) + assert unknown_event is not None + assert unknown_event.source.is_one_to_one is False # --------------------------------------------------------------------------- @@ -228,4 +234,3 @@ def test_whatsapp_streaming_follows_global(self): from gateway.display_config import resolve_display_setting # TIER_MEDIUM has streaming: None (follow global), not False assert resolve_display_setting({}, "whatsapp", "streaming") is None - diff --git a/tests/gateway/test_whatsapp_from_owner.py b/tests/gateway/test_whatsapp_from_owner.py index 1197c943c1283..69e9efd3552d7 100644 --- a/tests/gateway/test_whatsapp_from_owner.py +++ b/tests/gateway/test_whatsapp_from_owner.py @@ -77,6 +77,8 @@ def test_metadata_flag_set_when_payload_has_from_owner(): assert event.metadata.get("whatsapp_from_owner") is True assert event.text.startswith("[owner reply] ") assert event.text == "[owner reply] hi from the linked phone" + assert event.source.is_one_to_one is True + assert event.source.is_bot is False def test_from_owner_does_not_double_prefix_when_already_tagged(): @@ -92,4 +94,3 @@ def test_from_owner_does_not_double_prefix_when_already_tagged(): assert event.metadata.get("whatsapp_from_owner") is True assert event.text == "[owner reply] already tagged" - diff --git a/tests/tui_gateway/test_change_watcher.py b/tests/tui_gateway/test_change_watcher.py index 9c612c555f092..3dc86b1e1f248 100644 --- a/tests/tui_gateway/test_change_watcher.py +++ b/tests/tui_gateway/test_change_watcher.py @@ -240,6 +240,17 @@ def test_new_envelope_after_drain_fires_pending_again(watcher_home): ] +def test_desktop_room_command_signal_broadcasts_pending(watcher_home): + home, events = watcher_home + signal = home / "desktop_room_mailbox.pending" + server._broadcast_watched_changes(now=0.0) + + signal.write_text("1") + server._broadcast_watched_changes(now=10.0) + + assert ("desktop_rooms.commands.pending", {}) in events + + def test_no_outbox_dir_never_fires_pending(watcher_home): home, events = watcher_home server._broadcast_watched_changes(now=0.0) diff --git a/tests/tui_gateway/test_groups_methods.py b/tests/tui_gateway/test_groups_methods.py index 4ab00cdabacd6..9c92643ba3a90 100644 --- a/tests/tui_gateway/test_groups_methods.py +++ b/tests/tui_gateway/test_groups_methods.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib from types import SimpleNamespace import pytest @@ -73,25 +74,118 @@ def test_capabilities_are_honest_about_the_driver_boundary(home): assert "groups.send" in srv._LONG_HANDLERS assert "groups.retry" in result["methods"] assert "groups.approve" in result["methods"] + assert "groups.desktop.claim" in result["methods"] + assert "groups.desktop.presence" in result["methods"] + assert "groups.desktop.renew" in result["methods"] + assert "groups.desktop.complete" in result["methods"] advertised = [ str(value).lower() for value in (*result["features"], *result["methods"]) ] assert not any( token in value - for token in ("attachment", "desktop", "messaging") + for token in ("attachment", "messaging") for value in advertised ) assert result["room_link"]["enabled"] is True +def test_desktop_mailbox_rpc_claim_and_complete(home): + from gateway.desktop_room_mailbox import default_db_path, enqueue_command + + enqueue_command( + default_db_path(), + command_id="messaging:one", + room_id="classic-room", + authority_hash=hashlib.sha256(b"authority:test").hexdigest(), + action="send", + payload={"message": "hello"}, + ) + + claimed = _result( + srv._methods["groups.desktop.claim"]( + 1, + { + "consumer_id": "desktop:test", + "room_authorities": [ + { + "room_id": "classic-room", + "authority_token": "authority:test", + } + ], + }, + ) + )["commands"] + assert [item["command_id"] for item in claimed] == ["messaging:one"] + + renewed = _result( + srv._methods["groups.desktop.renew"]( + 2, + { + "consumer_id": "desktop:test", + "command_id": claimed[0]["command_id"], + "lease_token": claimed[0]["lease_token"], + }, + ) + )["command"] + assert renewed["state"] == "claimed" + + completed = _result( + srv._methods["groups.desktop.complete"]( + 3, + { + "consumer_id": "desktop:test", + "command_id": claimed[0]["command_id"], + "lease_token": claimed[0]["lease_token"], + "success": True, + "result": {"thread_id": "thread-1"}, + }, + ) + )["command"] + assert completed["state"] == "completed" + + +def test_desktop_presence_rpc_renews_without_claiming_work(home): + from gateway.desktop_room_mailbox import ( + default_db_path, + register_projected_authorities, + room_available, + ) + + register_projected_authorities( + default_db_path(), + [{ + "room_id": "classic-room", + "authority_hash": hashlib.sha256(b"authority:test").hexdigest(), + }], + ) + + result = _result( + srv._methods["groups.desktop.presence"]( + 1, + { + "consumer_id": "desktop:test", + "room_authorities": [{ + "room_id": "classic-room", + "authority_token": "authority:test", + }], + }, + ) + ) + + assert result == {"room_ids": ["classic-room"]} + assert room_available(default_db_path(), "classic-room") is True + + def test_capabilities_and_invitation_advertise_scoped_roomlink(home, monkeypatch): monkeypatch.setenv("API_SERVER_KEY", "gateway-api-key-1234567890") monkeypatch.setenv("HERMES_PROFILE", "reviewer") + (home / "profiles" / "reviewer").mkdir() result = _result(srv._methods["groups.capabilities"](1, {})) assert result["room_link"]["enabled"] is True assert result["room_link"]["profile"] == "reviewer" assert result["room_link"]["catalog"]["text"] is True assert "groups.peer.invite" in result["methods"] + assert "groups.peer.revoke_exact" in result["methods"] assert "groups.peer.register" in result["methods"] invitation = _result( @@ -118,6 +212,125 @@ def test_capabilities_and_invitation_advertise_scoped_roomlink(home, monkeypatch target_profile="reviewer", ) + exact = _result( + srv._methods["groups.peer.revoke_exact"]( + 3, + { + "grant": invitation["grant"], + "profile": "reviewer", + }, + ) + ) + assert exact == {"revoked": True} + + +def test_reciprocal_control_network_calls_stay_off_the_rpc_reader_thread(): + assert { + "groups.control.invite", + "groups.control.register", + "groups.control.revoke", + } <= srv._LONG_HANDLERS + + +def test_reciprocal_control_link_is_scoped_to_the_live_peer_reservation( + home, monkeypatch +): + from gateway import hosted_room_controls, hosted_rooms + + control_calls = [] + + class _ControlClient: + def __init__(self, link): + self.link = link + + def summary(self): + control_calls.append(("summary", self.link.room_id)) + return { + "room": { + "room_id": self.link.room_id, + "authority_gateway_id": self.link.authority_gateway_id, + "authority_epoch": self.link.authority_epoch, + } + } + + def revoke(self): + control_calls.append(("revoke", self.link.room_id)) + return None + + monkeypatch.setattr( + "gateway.hosted_room_control_client.RoomControlHTTPClient", + _ControlClient, + ) + + monkeypatch.setenv("HERMES_ROOM_LINK_URL", "https://home.example.test/hermes") + service = srv.get_hosted_room_service() + service.create_room( + room_id="room-control", + name="Control room", + members=[ + { + "member_id": "member-peer", + "profile": "ops", + "handle": "ops", + "target": { + "kind": "peer", + "peer_id": "install-peer", + "installation_id": "install-peer", + "profile": "ops", + "capability_digest": "a" * 64, + }, + }, + {"member_id": "default", "profile": "default", "handle": "hermes"}, + ], + ) + invitation = _result( + srv._methods["groups.control.invite"]( + 1, + { + "room_id": "room-control", + "member_id": "member-peer", + "caller_install_id": "install-peer", + "request_id": "control-room-member-peer-v1", + }, + ) + ) + hosted_rooms.reserve_peer_room( + hosted_rooms.default_db_path(), + claims={ + "room_id": "room-control", + "member_id": "member-peer", + "target_profile": "ops", + "authority_gateway_id": invitation["authority_gateway_id"], + "authority_epoch": invitation["authority_epoch"], + }, + expires_at=invitation["expires_at"], + ) + registered = _result( + srv._methods["groups.control.register"]( + 2, + {**invitation, "profile": "ops"}, + ) + ) + assert registered["registered"] is True + links = hosted_room_controls.load_peer_control_links( + hosted_rooms.default_db_path() + ).links + assert links[0].room_id == "room-control" + assert links[0].home_url == "https://home.example.test/hermes" + assert control_calls == [("summary", "room-control")] + + revoked = _result( + srv._methods["groups.control.revoke"]( + 3, + {"room_id": "room-control", "member_id": "member-peer"}, + ) + ) + assert revoked["revoked"] == 1 + assert control_calls == [ + ("summary", "room-control"), + ("revoke", "room-control"), + ] + def test_capabilities_disable_roomlink_when_run_replay_is_not_durable( home, monkeypatch @@ -366,12 +579,14 @@ def register_peer_route(self, **kwargs): "target_profile": "reviewer", "grant": "signed.room.grant", "catalog": catalog, + "expected_grant_sha256": "a" * 64, }, ) ) assert result["registered"] is True assert captured["api_key"] == "" assert captured["registered"]["target_url"] == ("https://peer.example.test") + assert captured["registered"]["expected_grant_sha256"] == "a" * 64 def test_register_rejects_plaintext_non_loopback(home, monkeypatch): @@ -739,8 +954,8 @@ def test_retry_and_approval_controls_forward_only_exact_local_coordinates( turn_id="turn-1", ) service = SimpleNamespace( - retry_room_task=lambda room_id, task_id: ( - calls.append(("retry", room_id, task_id)) + retry_room_task=lambda room_id, task_id, retry_id=None: ( + calls.append(("retry", room_id, task_id, retry_id)) or { "identity": identity, "status": "queued", @@ -757,9 +972,10 @@ def test_retry_and_approval_controls_forward_only_exact_local_coordinates( retried = _result( srv._methods["groups.retry"]( 1, - {"room_id": "room-1", "task_id": "task-1"}, + {"room_id": "room-1", "task_id": "task-1", "command_id": "retry-1"}, ) ) + assert calls[0] == ("retry", "room-1", "task-1", "retry-1") approved = _result( srv._methods["groups.approve"]( 2, @@ -785,7 +1001,7 @@ def test_retry_and_approval_controls_forward_only_exact_local_coordinates( } assert approved == {"approved": True, "result": {"resolved": 1}} assert calls == [ - ("retry", "room-1", "task-1"), + ("retry", "room-1", "task-1", "retry-1"), ( "approve", "room-1", @@ -923,9 +1139,17 @@ def revoke_room_routes(self, room_id): calls.append(("revoke", room_id)) monkeypatch.setattr(srv, "get_hosted_room_service", lambda: FakeService()) + monkeypatch.setattr( + "gateway.hosted_room_controls.revoke_home_control_tokens", + lambda _db_path, *, room_id: calls.append(("revoke-controls", room_id)), + ) _result(srv._methods["groups.disband"](9, {"room_id": "room-1"})) - assert calls == [("stop", "room-1"), ("revoke", "room-1")] + assert calls == [ + ("stop", "room-1"), + ("revoke", "room-1"), + ("revoke-controls", "room-1"), + ] assert _result(srv._methods["groups.list"](10, {}))["rooms"] == [] diff --git a/tests/tui_gateway/test_groups_replication_methods.py b/tests/tui_gateway/test_groups_replication_methods.py index 642a0561fac88..325b0c94e9d19 100644 --- a/tests/tui_gateway/test_groups_replication_methods.py +++ b/tests/tui_gateway/test_groups_replication_methods.py @@ -1,5 +1,4 @@ -"""Tests for the ``groups.replicate`` / ``groups.promote`` / ``groups.demote`` -JSON-RPC surface — cross-gateway room durability.""" +"""Tests for the fail-closed passive-replica JSON-RPC surface.""" from __future__ import annotations @@ -8,8 +7,6 @@ import tui_gateway.server as srv from tui_gateway import methods_groups -MEMBERS = [{"kind": "bot", "id": "planner"}] - @pytest.fixture def home(tmp_path, monkeypatch): @@ -33,157 +30,21 @@ def _error(envelope): return envelope["error"] -def _authority_page(tmp_path, gateway_id="install:" + "a" * 32, n=3): - """Build a real room + log on a SEPARATE 'remote authority' DB and return - its replay page, as a replicating client would fetch via groups.log.""" - from gateway import hosted_rooms as rooms - - db = tmp_path / "remote-authority.db" - rooms.create_room( - db, - room_id="room-1", - name="Field Room", - members=MEMBERS, - authority_gateway_id=gateway_id, - ) - for index in range(n): - rooms.append_event( - db, - room_id="room-1", - event_id=f"e{index}", - kind="message.user", - actor={"kind": "user", "id": "tek"}, - payload={"text": f"msg {index}"}, - authority_gateway_id=gateway_id, - authority_epoch=1, - ) - return rooms.read_events(db, room_id="room-1", since_seq=0, limit=100) - - -def test_capabilities_advertise_replication(home): +def test_capabilities_do_not_advertise_unverified_replication(home): result = _result(srv._methods["groups.capabilities"](1, {})) - assert "log_replication" in result["features"] - assert "authority_takeover" in result["features"] - for name in ( - "groups.replicate", - "groups.replica_state", - "groups.promote", - "groups.demote", - ): - assert name in result["methods"] - assert name in srv._LONG_HANDLERS - - -def test_replicate_then_state_roundtrip(home, tmp_path): - page = _authority_page(tmp_path) - result = _result( - srv._methods["groups.replicate"]( - 1, - { - "room_id": "room-1", - "room_name": "Field Room", - "members": MEMBERS, - "page": page, - }, - ) - ) - assert result["ingested"] == 3 - state = _result(srv._methods["groups.replica_state"](2, {"room_id": "room-1"})) - assert state["last_seq"] == 3 - assert state["authority"] == page["authority"] - - -def test_promote_requires_confirm_and_takes_over(home, tmp_path): - page = _authority_page(tmp_path) - _result( - srv._methods["groups.replicate"]( - 1, - { - "room_id": "room-1", - "room_name": "Field Room", - "members": MEMBERS, - "page": page, - }, - ) - ) - - refused = _error(srv._methods["groups.promote"](2, {"room_id": "room-1"})) - assert refused["code"] == 4118 - - promoted = _result( - srv._methods["groups.promote"](3, {"room_id": "room-1", "confirm": True}) - ) - assert promoted["authority_epoch"] == 2 - assert promoted["previous_gateway_id"] == page["authority"]["gateway_id"] - - # The room is now hosted locally with full history + claim event. - log = _result(srv._methods["groups.log"](4, {"room_id": "room-1"})) - kinds = [event["kind"] for event in log["events"]] - assert kinds == ["message.user"] * 3 + ["authority.claimed"] - assert log["authority"]["epoch"] == 2 - - -def test_demote_fences_local_room_against_newer_epoch(home): - from gateway.hosted_rooms import local_authority_gateway_id - - _result( - srv._methods["groups.create"]( - 1, - { - "room_id": "room-1", - "name": "Local room", - "members": [ - { - "member_id": "default", - "profile": "default", - "handle": "hermes", - }, - {"member_id": "ops", "profile": "ops", "handle": "ops"}, - ], - }, - ) - ) - observed_gateway = "install:" + "b" * 32 - result = _result( - srv._methods["groups.demote"]( - 2, - { - "room_id": "room-1", - "observed_gateway_id": observed_gateway, - "observed_epoch": 2, - }, - ) - ) - assert result["idempotent"] is False - assert result["authority_gateway_id"] == observed_gateway - - # Local sends at the stale authority now fail. - envelope = srv._methods["groups.send"]( - 3, - { - "room_id": "room-1", - "event_id": "stale-send", - "actor": {"kind": "user", "id": "tek"}, - "payload": {"text": "should fence"}, - }, - ) - assert "error" in envelope - assert local_authority_gateway_id() != observed_gateway - - -def test_replicate_rejects_gapped_page(home, tmp_path): - from gateway import hosted_rooms as rooms - - _authority_page(tmp_path, n=5) - db = tmp_path / "remote-authority.db" - gapped = rooms.read_events(db, room_id="room-1", since_seq=2, limit=100) - envelope = srv._methods["groups.replicate"]( - 1, - { - "room_id": "room-1", - "room_name": "Field Room", - "members": MEMBERS, - "page": gapped, - }, - ) - assert _error(envelope)["code"] == 4116 + assert "log_replication" not in result["features"] + assert "authority_takeover" not in result["features"] + assert "groups.replicate" not in result["methods"] + assert "groups.replicate" not in srv._LONG_HANDLERS + assert "groups.replica_state" in result["methods"] + assert "groups.replica_state" in srv._LONG_HANDLERS + blocked = srv._methods["groups.replicate"]( + 2, + {"room_id": "forged", "page": {"authority": {"epoch": 1}}}, + ) + assert _error(blocked)["data"]["reason"] == "replica_provenance_required" + for name in ("groups.promote", "groups.demote"): + assert name not in result["methods"] + assert name not in srv._LONG_HANDLERS + envelope = srv._methods[name](3, {"room_id": "room-1", "confirm": True}) + assert _error(envelope)["data"]["reason"] == "authority_takeover_disabled" diff --git a/tests/tui_gateway/test_hosted_room_grant_fingerprint.py b/tests/tui_gateway/test_hosted_room_grant_fingerprint.py new file mode 100644 index 0000000000000..a409c73f3ac1e --- /dev/null +++ b/tests/tui_gateway/test_hosted_room_grant_fingerprint.py @@ -0,0 +1,116 @@ +"""Focused contract test for reconnect grant identity.""" + +from __future__ import annotations + +import hashlib +import threading +from types import SimpleNamespace + +import pytest + +from tui_gateway.hosted_room_peer_transport import PeerMemberRoute +from tui_gateway.hosted_room_service import HostedRoomService + + +def _route(grant: str) -> PeerMemberRoute: + return PeerMemberRoute( + home_install_id="install-home", + member_id="member-peer", + target_install_id="install-peer", + target_profile="reviewer", + capability_digest="catalog-digest", + cancellation_scope_id="cancel-scope", + trace_id="trace-id", + grant=grant, + ) + + +def _service() -> HostedRoomService: + service = object.__new__(HostedRoomService) + service._policy_lock = threading.RLock() + service._peer_route_status = {("room-1", "member-peer"): "ready"} + service.peer_routes = {("room-1", "member-peer"): _route("signed.room.grant")} + service.peer_clients = {} + service.runtime = SimpleNamespace(wakeup=lambda: None) + service.status = lambda _room_id: { + "working": False, + "peer_routes": service._route_statuses("room-1"), + } + return service + + +def test_route_status_exposes_only_the_grant_fingerprint(): + service = _service() + status = service.status_with_grant_fingerprints("room-1") + + assert status["peer_routes"] == [ + { + "room_id": "room-1", + "member_id": "member-peer", + "status": "ready", + "grant_sha256": hashlib.sha256(b"signed.room.grant").hexdigest(), + } + ] + assert "signed.room.grant" not in repr(status) + + +def test_route_status_and_fingerprint_are_one_locked_snapshot(): + service = _service() + status_started = threading.Event() + rotated = threading.Event() + + def status(_room_id): + status_started.set() + assert not rotated.wait(0.05) + return {"peer_routes": service._route_statuses("room-1")} + + def rotate(): + status_started.wait() + with service._policy_lock: + service.peer_routes[("room-1", "member-peer")] = _route( + "replacement.room.grant" + ) + rotated.set() + + service.status = status + worker = threading.Thread(target=rotate) + worker.start() + snapshot = service.status_with_grant_fingerprints("room-1") + worker.join(timeout=1) + + assert rotated.is_set() + assert snapshot["peer_routes"][0]["grant_sha256"] == hashlib.sha256( + b"signed.room.grant" + ).hexdigest() + + +def test_peer_registration_compares_and_swaps_the_observed_grant(): + service = _service() + old_sha256 = hashlib.sha256(b"signed.room.grant").hexdigest() + winner = _route("winner.room.grant") + + with pytest.raises(RuntimeError, match="changed during reconnect"): + service.register_peer_route( + room_id="room-1", + member_id="member-peer", + route=winner, + client=object(), + expected_grant_sha256="0" * 64, + ) + + assert service.peer_routes[("room-1", "member-peer")].grant == "signed.room.grant" + service.register_peer_route( + room_id="room-1", + member_id="member-peer", + route=winner, + client=object(), + expected_grant_sha256=old_sha256, + ) + service.register_peer_route( + room_id="room-1", + member_id="member-peer", + route=winner, + client=object(), + expected_grant_sha256=old_sha256, + ) + assert service.peer_routes[("room-1", "member-peer")].grant == "winner.room.grant" diff --git a/tests/tui_gateway/test_hosted_room_messaging_retry.py b/tests/tui_gateway/test_hosted_room_messaging_retry.py new file mode 100644 index 0000000000000..0c98fe6b479e8 --- /dev/null +++ b/tests/tui_gateway/test_hosted_room_messaging_retry.py @@ -0,0 +1,325 @@ +"""Cross-process hosted Group Chat retry ownership tests.""" + +from __future__ import annotations + +import time +from pathlib import Path + +import pytest + +from gateway import hosted_room_controls, hosted_rooms +from gateway import hosted_room_driver as driver +from tests.tui_gateway.test_hosted_room_service import _FakeRPC, _server +from tui_gateway.hosted_room_service import HostedRoomService + + +@pytest.mark.parametrize( + "mode", + ["normal", "lease_takeover", "stopped", "already_cancelled"], +) +def test_active_worker_applies_retry_queued_by_another_process( + tmp_path: Path, + monkeypatch, + mode: str, +): + now = [100.0] + + def clock(): + return now[0] + + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + service.rpc = _FakeRPC() + service.runtime.rpc = service.rpc + service.runtime.clock = clock + service.runtime.lease_ttl_seconds = 30 + service.local_profiles = lambda: ("default", "ops") + service.create_room( + room_id="room-1", + name="Cross-process retry", + members=[ + {"member_id": "default", "profile": "default", "handle": "default"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + service.send( + room_id="room-1", + event_id="user-1", + payload={"text": "Retry this", "thread_id": "thread-1"}, + ) + task = driver.list_tasks(db, room_id="room-1", status="queued")[0] + old_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation="old-process", + ttl_seconds=1, + clock=clock, + ) + attempt = driver.start_task( + db, + task["identity"], + old_lease, + expected_cancel_generation=0, + clock=clock, + ) + now[0] = 102.0 + owner_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation=service.runtime.process_generation, + ttl_seconds=30, + clock=clock, + ) + driver.recover_room(db, owner_lease, clock=clock) + driver.defer_indeterminate_task( + db, + task["identity"], + owner_lease, + expected_execution_generation=attempt.execution_generation, + expected_cancel_generation=attempt.cancel_generation, + reason="member_unavailable", + clock=clock, + ) + hosted_room_controls.begin_control_retry( + db, + command_id="retry-command-1", + room_id="room-1", + member_id="messaging-owner", + task_ids=[task["identity"].task_id], + now=now[0], + ) + service.runtime._leases["room-1"] = owner_lease + + if mode in {"stopped", "already_cancelled"}: + room = hosted_rooms.room_state(db, room_id="room-1") + hosted_rooms.request_room_stop( + db, + room_id="room-1", + cancel_id="stop-before-retry", + expected_gateway_id=str(room["authority_gateway_id"]), + expected_epoch=int(room["authority_epoch"]), + ) + if mode == "already_cancelled": + service.runtime.cancel( + task["identity"], + cancel_id="stop-before-retry", + ) + + if mode == "lease_takeover": + real_complete_control_retry = hosted_room_controls.complete_control_retry + expire_once = [True] + + def complete_after_takeover(*args, **kwargs): + if expire_once[0]: + expire_once[0] = False + now[0] = owner_lease.expires_at + driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation="takeover-worker", + ttl_seconds=30, + clock=clock, + ) + return real_complete_control_retry(*args, **kwargs) + + monkeypatch.setattr( + hosted_room_controls, + "complete_control_retry", + complete_after_takeover, + ) + + if mode == "lease_takeover": + with pytest.raises(driver.StaleLeaseError): + service.runtime._process_room(service.bindings()[0]) + assert len( + hosted_room_controls.load_pending_control_retries( + db, + room_id="room-1", + ) + ) == 1 + service.runtime.process_generation = "takeover-worker" + service.runtime._leases.clear() + service.runtime._process_room(service.bindings()[0]) + else: + service.runtime._process_room(service.bindings()[0]) + + completed = hosted_room_controls.begin_control_retry( + db, + command_id="retry-command-1", + room_id="room-1", + member_id="messaging-owner", + task_ids=[task["identity"].task_id], + now=now[0] + 1, + ) + assert completed.result == {"action": "retry", "processed": 1} + assert driver.get_task(db, task["identity"])["status"] == ( + "cancelled" + if mode in {"stopped", "already_cancelled"} + else "settled" + ) + if mode not in {"stopped", "already_cancelled"}: + assert driver.retry_receipt_exists( + db, + room_id="room-1", + task_id=task["identity"].task_id, + retry_id=hosted_room_controls.control_retry_attempt_id( + "retry-command-1", + task["identity"].task_id, + ), + ) + if mode in {"stopped", "already_cancelled"}: + assert not any( + event["kind"] == "message.member" + for event in service._events("room-1") + ) + + +def test_worker_retry_redelivery_does_not_requeue_a_later_generation( + tmp_path: Path, + monkeypatch, +): + now = [100.0] + + def clock(): + return now[0] + + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + service.rpc = _FakeRPC() + service.runtime.rpc = service.rpc + service.runtime.clock = clock + service.runtime.lease_ttl_seconds = 30 + service.local_profiles = lambda: ("default", "ops") + service.create_room( + room_id="room-1", + name="Retry redelivery", + members=[ + {"member_id": "default", "profile": "default", "handle": "default"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + service.send( + room_id="room-1", + event_id="user-1", + payload={"text": "Retry this", "thread_id": "thread-1"}, + ) + task = driver.list_tasks(db, room_id="room-1", status="queued")[0] + expired_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation="expired-process", + ttl_seconds=1, + clock=clock, + ) + first_attempt = driver.start_task( + db, + task["identity"], + expired_lease, + expected_cancel_generation=0, + clock=clock, + ) + now[0] = 102.0 + owner_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation="owner-process", + ttl_seconds=30, + clock=clock, + ) + driver.recover_room(db, owner_lease, clock=clock) + driver.defer_indeterminate_task( + db, + task["identity"], + owner_lease, + expected_execution_generation=first_attempt.execution_generation, + expected_cancel_generation=first_attempt.cancel_generation, + reason="member_unavailable", + clock=clock, + ) + hosted_room_controls.begin_control_retry( + db, + command_id="retry-command-1", + room_id="room-1", + member_id="messaging-owner", + task_ids=[task["identity"].task_id], + now=now[0], + ) + + real_complete = hosted_room_controls.complete_control_retry + lose_first_completion = [True] + + def complete_after_lost_response(*args, **kwargs): + if lose_first_completion[0]: + lose_first_completion[0] = False + raise driver.StaleLeaseError("simulated lost completion") + return real_complete(*args, **kwargs) + + monkeypatch.setattr( + hosted_room_controls, + "complete_control_retry", + complete_after_lost_response, + ) + service.runtime._leases["room-1"] = owner_lease + service._apply_pending_control_retries(service.bindings()[0], owner_lease) + + retry_id = hosted_room_controls.control_retry_attempt_id( + "retry-command-1", + task["identity"].task_id, + ) + assert driver.retry_receipt_exists( + db, + room_id="room-1", + task_id=task["identity"].task_id, + retry_id=retry_id, + ) + assert driver.get_task(db, task["identity"])["status"] == "queued" + assert len( + hosted_room_controls.load_pending_control_retries(db, room_id="room-1") + ) == 1 + + second_attempt = driver.start_task( + db, + task["identity"], + owner_lease, + expected_cancel_generation=0, + clock=clock, + ) + now[0] = owner_lease.expires_at + next_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation="next-process", + ttl_seconds=30, + clock=clock, + ) + driver.recover_room(db, next_lease, clock=clock) + driver.defer_indeterminate_task( + db, + task["identity"], + next_lease, + expected_execution_generation=second_attempt.execution_generation, + expected_cancel_generation=second_attempt.cancel_generation, + reason="member_unavailable_again", + clock=clock, + ) + + service.runtime._leases["room-1"] = next_lease + service._apply_pending_control_retries(service.bindings()[0], next_lease) + + assert driver.get_task(db, task["identity"])["status"] == "deferred" + assert hosted_room_controls.load_pending_control_retries( + db, + room_id="room-1", + ) == () diff --git a/tests/tui_gateway/test_hosted_room_service.py b/tests/tui_gateway/test_hosted_room_service.py index 0d1e3b731dfef..713e859522435 100644 --- a/tests/tui_gateway/test_hosted_room_service.py +++ b/tests/tui_gateway/test_hosted_room_service.py @@ -788,8 +788,51 @@ def clock(): requeued = service.retry_room_task( "room-1", task_id=first["identity"].task_id, + retry_id="retry-1", ) assert requeued["status"] == "queued" + replayed = service.retry_room_task( + "room-1", + task_id=first["identity"].task_id, + retry_id="retry-1", + ) + assert replayed["status"] == "queued" + assert replayed["idempotent"] is True + with sqlite3.connect(db) as conn: + conn.execute( + """UPDATE hosted_room_driver_tasks + SET status='deferred', execution_generation=2 + WHERE room_id='room-1' AND task_id=?""", + (first["identity"].task_id,), + ) + conn.commit() + second_retry = service.retry_room_task( + "room-1", + task_id=first["identity"].task_id, + retry_id="retry-2", + ) + assert second_retry["status"] == "queued" + with sqlite3.connect(db) as conn: + conn.execute( + """UPDATE hosted_room_driver_tasks + SET status='deferred', execution_generation=3 + WHERE room_id='room-1' AND task_id=?""", + (first["identity"].task_id,), + ) + conn.commit() + delayed_first = service.retry_room_task( + "room-1", + task_id=first["identity"].task_id, + retry_id="retry-1", + ) + assert delayed_first["status"] == "deferred" + assert delayed_first["idempotent"] is True + third_retry = service.retry_room_task( + "room-1", + task_id=first["identity"].task_id, + retry_id="retry-3", + ) + assert third_retry["status"] == "queued" lease = service.runtime._leases["room-1"] retried = driver.start_task( db, @@ -798,7 +841,7 @@ def clock(): expected_cancel_generation=0, clock=clock, ) - assert retried.execution_generation == old_attempt.execution_generation + 1 + assert retried.execution_generation == third_retry["execution_generation"] + 1 def test_stop_fence_prevents_the_next_room_member_from_starting( diff --git a/tui_gateway/hosted_room_driver.py b/tui_gateway/hosted_room_driver.py index 3223df4d22f7d..38d249f38fd78 100644 --- a/tui_gateway/hosted_room_driver.py +++ b/tui_gateway/hosted_room_driver.py @@ -133,6 +133,10 @@ def __init__( rpc: InternalSessionRPC | None = None, transport_resolver: MemberTransportResolver | None = None, prepare_room: Callable[[HostedRoomBinding], None] | None = None, + prepare_leased_room: Callable[ + [HostedRoomBinding, state.DriverLease], None + ] + | None = None, publish_terminal: Callable[[HostedRoomBinding, Mapping[str, Any]], None] | None = None, pending_action: Callable[[str, str, Mapping[str, Any] | None], None] @@ -176,6 +180,7 @@ def __init__( self.transport_resolver = transport_resolver self.turn_lock = turn_lock self.prepare_room = prepare_room + self.prepare_leased_room = prepare_leased_room self.publish_terminal = publish_terminal self.pending_action = pending_action self.clock = clock @@ -298,7 +303,7 @@ def cancel( raise state.InvalidTaskTransitionError( f"cannot cancel task in state '{before['status']}'" ) - if before["status"] in {"queued", "deferred"}: + if before["status"] == "queued": try: cancelled = state.cancel_task( self.db_path, @@ -407,7 +412,12 @@ def _peer_stop_acknowledged( ) return self._info_acknowledges_peer_cancel(info, task) - def retry_indeterminate(self, identity: state.TaskIdentity) -> dict[str, Any]: + def retry_indeterminate( + self, + identity: state.TaskIdentity, + *, + retry_id: str | None = None, + ) -> dict[str, Any]: """Explicitly retry one uncertain attempt under the current room lease.""" task = state.get_task(self.db_path, identity) if task["status"] not in {"indeterminate", "deferred"}: @@ -426,6 +436,7 @@ def retry_indeterminate(self, identity: state.TaskIdentity) -> dict[str, Any]: expected_execution_generation=task["execution_generation"], expected_cancel_generation=task["cancel_generation"], clock=self.clock, + retry_id=retry_id, ) with self._status_lock: self._blocked_rooms.discard(identity.room_id) @@ -446,6 +457,7 @@ def retry_indeterminate(self, identity: state.TaskIdentity) -> dict[str, Any]: status=inspection.terminal.status, result=inspection.terminal.result, clock=self.clock, + retry_id=retry_id, ) if self.publish_terminal is not None: self.publish_terminal(binding, resolved) @@ -459,6 +471,7 @@ def retry_indeterminate(self, identity: state.TaskIdentity) -> dict[str, Any]: expected_cancel_generation=task["cancel_generation"], cancel_id=f"remote-cancel:{task['execution_generation']}", clock=self.clock, + retry_id=retry_id, ) if self.publish_terminal is not None: self.publish_terminal(binding, resolved) @@ -476,6 +489,7 @@ def retry_indeterminate(self, identity: state.TaskIdentity) -> dict[str, Any]: expected_execution_generation=task["execution_generation"], expected_cancel_generation=task["cancel_generation"], clock=self.clock, + retry_id=retry_id, ) with self._status_lock: self._blocked_rooms.discard(identity.room_id) @@ -765,6 +779,8 @@ def _process_room(self, binding: HostedRoomBinding) -> None: if recovery_key not in self._recovered_leases: state.recover_room(self.db_path, lease, clock=self.clock) self._recovered_leases.add(recovery_key) + if self.prepare_leased_room is not None: + self.prepare_leased_room(binding, lease) if self._retry_stopping_tasks(binding, lease): with self._status_lock: self._blocked_rooms.add(binding.room_id) @@ -790,6 +806,8 @@ def _process_room(self, binding: HostedRoomBinding) -> None: expected_cancel_generation=task["cancel_generation"], clock=self.clock, ) + if attempt is None: + continue self._execute_attempt(binding, task, attempt) current = state.get_task(self.db_path, task["identity"]) if current["status"] not in state.TERMINAL_STATUSES: diff --git a/tui_gateway/hosted_room_service.py b/tui_gateway/hosted_room_service.py index 52943879cabed..b5609739153ad 100644 --- a/tui_gateway/hosted_room_service.py +++ b/tui_gateway/hosted_room_service.py @@ -4,6 +4,7 @@ import contextlib import hashlib +import logging import os import threading import time @@ -15,6 +16,7 @@ from typing import Any from gateway import hosted_room_discussion as discussion +from gateway import hosted_room_controls from gateway import hosted_room_driver as driver from gateway import hosted_room_links from gateway import hosted_rooms @@ -37,6 +39,9 @@ ) +logger = logging.getLogger(__name__) + + _HOSTED_ROOM_IDLE_FALLBACK_SECONDS = 5.0 _HOSTED_ROOM_ACTIVE_POLL_SECONDS = 0.25 _HOSTED_ROOM_TERMINAL_GRACE_SECONDS = 30.0 @@ -137,6 +142,7 @@ def __init__( transport_resolver=self._resolve_member_transport, turn_lock=self._turn_lock, prepare_room=self.prepare_room, + prepare_leased_room=self._apply_pending_control_retries, publish_terminal=self.publish_terminal, pending_action=self._set_pending_action, poll_interval_seconds=_HOSTED_ROOM_IDLE_FALLBACK_SECONDS, @@ -204,6 +210,7 @@ def register_peer_route( client: HostedRoomPeerClient, target_url: str | None = None, catalog: GatewayRoomCatalog | None = None, + expected_grant_sha256: str | None = None, ) -> None: """Register one verified route and optionally persist its scoped grant.""" bind_store = getattr(client, "bind_receipt_store", None) @@ -223,23 +230,37 @@ def register_peer_route( != catalog.execution_policy.policy_digest ): raise ValueError("peer route does not match its target catalog") - if target_url is not None and catalog is not None: - hosted_room_links.save_room_link( - self.db_path, - hosted_room_links.make_stored_link( - room_id=room_id, - member_id=member_id, - target_url=target_url, - target_profile=route.target_profile, - grant=route.grant, - catalog=catalog, - cancellation_scope_id=route.cancellation_scope_id, - trace_id=route.trace_id, - ), - ) # Persistence is the publication boundary. A failed disk write must - # never leave a process-local route that disappears after restart. + # never leave a process-local route that disappears after restart. The + # same lock makes reconnect a compare-and-swap against grant rotation. with self._policy_lock: + current = self.peer_routes.get((room_id, member_id)) + current_sha256 = ( + hashlib.sha256(current.grant.encode("utf-8")).hexdigest() + if current is not None + else "" + ) + incoming_sha256 = hashlib.sha256(route.grant.encode("utf-8")).hexdigest() + if ( + current_sha256 != incoming_sha256 + and expected_grant_sha256 is not None + and current_sha256 != expected_grant_sha256 + ): + raise RuntimeError("peer route changed during reconnect") + if target_url is not None and catalog is not None: + hosted_room_links.save_room_link( + self.db_path, + hosted_room_links.make_stored_link( + room_id=room_id, + member_id=member_id, + target_url=target_url, + target_profile=route.target_profile, + grant=route.grant, + catalog=catalog, + cancellation_scope_id=route.cancellation_scope_id, + trace_id=route.trace_id, + ), + ) self.peer_routes[(room_id, member_id)] = route self.peer_clients[(room_id, member_id)] = client self._peer_route_status[(room_id, member_id)] = "ready" @@ -421,62 +442,62 @@ def _rotate_route_grant( catalog: GatewayRoomCatalog | None = None, ) -> None: """Persist a target-refreshed scoped grant before publishing it live.""" - key = (room_id, member_id) - route = self.peer_routes.get(key) - if route is None: - raise RuntimeError("peer room route is unavailable") - stored = next( - ( - link - for link in hosted_room_links.load_room_links(self.db_path) - if (link.room_id, link.member_id) == key - ), - None, - ) - if stored is None: - raise RuntimeError("peer room route cannot be renewed before persistence") - effective_catalog = catalog or stored.catalog - if catalog is not None and ( - catalog.installation_id != route.target_install_id - or catalog.execution_policy.target_profile != route.target_profile - or PROTOCOL_VERSION not in catalog.protocol_versions - or "direct" not in catalog.link_modes - or not catalog.text - or catalog.execution_policy.policy_digest - != route.execution_policy_digest - ): - self._set_route_status(room_id, member_id, "needs_reauthorization") - raise RuntimeError( - "peer room execution policy changed; reauthorization is required" + with self._policy_lock: + key = (room_id, member_id) + route = self.peer_routes.get(key) + if route is None: + raise RuntimeError("peer room route is unavailable") + stored = next( + ( + link + for link in hosted_room_links.load_room_links(self.db_path) + if (link.room_id, link.member_id) == key + ), + None, ) - rotated_route = replace( - route, - grant=grant, - capability_digest=( - catalog.catalog_digest - if catalog is not None - else route.capability_digest - ), - execution_policy_digest=( - catalog.execution_policy.policy_digest - if catalog is not None - else route.execution_policy_digest - ), - ) - hosted_room_links.save_room_link( - self.db_path, - hosted_room_links.make_stored_link( - room_id=room_id, - member_id=member_id, - target_url=stored.target_url, - target_profile=stored.target_profile, + if stored is None: + raise RuntimeError("peer room route cannot be renewed before persistence") + effective_catalog = catalog or stored.catalog + if catalog is not None and ( + catalog.installation_id != route.target_install_id + or catalog.execution_policy.target_profile != route.target_profile + or PROTOCOL_VERSION not in catalog.protocol_versions + or "direct" not in catalog.link_modes + or not catalog.text + or catalog.execution_policy.policy_digest + != route.execution_policy_digest + ): + self._set_route_status(room_id, member_id, "needs_reauthorization") + raise RuntimeError( + "peer room execution policy changed; reauthorization is required" + ) + rotated_route = replace( + route, grant=grant, - catalog=effective_catalog, - cancellation_scope_id=stored.cancellation_scope_id, - trace_id=stored.trace_id, - ), - ) - with self._policy_lock: + capability_digest=( + catalog.catalog_digest + if catalog is not None + else route.capability_digest + ), + execution_policy_digest=( + catalog.execution_policy.policy_digest + if catalog is not None + else route.execution_policy_digest + ), + ) + hosted_room_links.save_room_link( + self.db_path, + hosted_room_links.make_stored_link( + room_id=room_id, + member_id=member_id, + target_url=stored.target_url, + target_profile=stored.target_profile, + grant=grant, + catalog=effective_catalog, + cancellation_scope_id=stored.cancellation_scope_id, + trace_id=stored.trace_id, + ), + ) self.peer_routes[key] = rotated_route self._peer_route_status[key] = "ready" @@ -493,6 +514,33 @@ def _route_statuses(self, room_id: str | None = None) -> list[dict[str, str]]: ] return sorted(rows, key=lambda row: (row["room_id"], row["member_id"])) + def status_with_grant_fingerprints(self, room_id: str) -> dict[str, Any]: + """Snapshot reconnect status and non-secret grant identity atomically.""" + with self._policy_lock: + status = self.status(room_id) + return { + **status, + "peer_routes": [ + { + **row, + **( + { + "grant_sha256": hashlib.sha256( + route.grant.encode("utf-8") + ).hexdigest() + } + if ( + route := self.peer_routes.get( + (room_id, str(row.get("member_id") or "")) + ) + ) + else {} + ), + } + for row in status.get("peer_routes", []) + ], + } + def _events(self, room_id: str) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] cursor = 0 @@ -657,6 +705,73 @@ def prepare_room(self, binding: HostedRoomBinding) -> None: elif decision.status in {"settled", "bounded"}: self._append_room_status(room, decision) + def _apply_pending_control_retries( + self, + binding: HostedRoomBinding, + lease: driver.DriverLease, + ) -> None: + """Apply cross-process Retry commands under this worker's active lease.""" + + pending = hosted_room_controls.load_pending_control_retries( + self.db_path, + room_id=binding.room_id, + ) + if not pending: + return + room = hosted_rooms.room_state(self.db_path, room_id=binding.room_id) + stopped_through_seq = self._policy_snapshot(room).stopped_through_seq + tasks = { + task["identity"].task_id: task + for task in driver.list_tasks(self.db_path, room_id=binding.room_id) + } + for command in pending: + try: + for task_id in command.task_ids: + task = tasks.get(task_id) + if task is None: + continue + status = str(task.get("status") or "") + if status in driver.TERMINAL_STATUSES or status == "stopping": + continue + source_event_seq = int( + (task.get("payload") or {}).get("source_event_seq") or 0 + ) + if source_event_seq < stopped_through_seq: + self.runtime.cancel( + task["identity"], + cancel_id=f"stop-fence:{stopped_through_seq}", + ) + continue + if status in {"deferred", "indeterminate"}: + self.retry_room_task( + binding.room_id, + task_id=task_id, + retry_id=hosted_room_controls.control_retry_attempt_id( + command.command_id, task_id + ), + ) + hosted_room_controls.complete_control_retry( + self.db_path, + command_id=command.command_id, + result={ + "action": "retry", + "processed": len(command.task_ids), + }, + lease=lease, + now=self.runtime.clock(), + ) + except Exception as exc: + hosted_room_controls.defer_control_retry( + self.db_path, + command_id=command.command_id, + now=self.runtime.clock(), + ) + logger.warning( + "Hosted room retry command %s remains pending: %s", + command.command_id, + exc, + ) + def publish_terminal( self, binding: HostedRoomBinding, @@ -700,6 +815,23 @@ def send( event_id: str, payload: Any, ) -> dict[str, Any]: + return self.send_server_owned( + room_id=room_id, + event_id=event_id, + payload=payload, + actor={"kind": "user", "id": "desktop"}, + ) + + def send_server_owned( + self, + *, + room_id: str, + event_id: str, + payload: Any, + actor: Mapping[str, Any], + ) -> dict[str, Any]: + """Append a user event whose actor was derived by trusted gateway code.""" + normalized = discussion.validate_user_payload(payload) room = self._owned_room(room_id) event = hosted_rooms.append_event( @@ -707,7 +839,7 @@ def send( room_id=room_id, event_id=event_id, kind="message.user", - actor={"kind": "user", "id": "desktop"}, + actor=dict(actor), payload=normalized, authority_gateway_id=str(room["authority_gateway_id"]), authority_epoch=int(room["authority_epoch"]), @@ -779,16 +911,19 @@ def stop_room( self.runtime.wakeup() return cancelled - def retry_room_task(self, room_id: str, *, task_id: str) -> dict[str, Any]: + def retry_room_task( + self, + room_id: str, + *, + task_id: str, + retry_id: str | None = None, + ) -> dict[str, Any]: """Retry one uncertain or deferred task only after explicit user action.""" task = next( ( candidate - for status in ("indeterminate", "deferred") - for candidate in driver.list_tasks( - self.db_path, room_id=room_id, status=status - ) + for candidate in driver.list_tasks(self.db_path, room_id=room_id) if candidate["identity"].task_id == task_id ), None, @@ -797,7 +932,18 @@ def retry_room_task(self, room_id: str, *, task_id: str) -> dict[str, Any]: raise driver.InvalidTaskTransitionError( "no retryable room task matches task_id" ) - return self.runtime.retry_indeterminate(task["identity"]) + if retry_id and driver.retry_receipt_exists( + self.db_path, + room_id=room_id, + task_id=task_id, + retry_id=retry_id, + ): + return {**task, "idempotent": True} + if task["status"] not in {"indeterminate", "deferred"}: + raise driver.InvalidTaskTransitionError( + "no retryable room task matches task_id" + ) + return self.runtime.retry_indeterminate(task["identity"], retry_id=retry_id) def approve_room_task( self, diff --git a/tui_gateway/methods_groups.py b/tui_gateway/methods_groups.py index 2130bee00c14d..aae6cc529aa2a 100644 --- a/tui_gateway/methods_groups.py +++ b/tui_gateway/methods_groups.py @@ -9,6 +9,7 @@ import os import threading +import time _registry = HandlerRegistry() method = _registry.method @@ -22,16 +23,17 @@ "groups.rename", "groups.log", "groups.disband", - "groups.replicate", "groups.replica_state", - "groups.promote", - "groups.demote", "groups.stop", "groups.retry", "groups.approve", "groups.peer.invite", + "groups.peer.revoke_exact", "groups.peer.revoke", "groups.peer.register", + "groups.control.invite", + "groups.control.register", + "groups.control.revoke", }) _service_lock = threading.Lock() @@ -46,6 +48,7 @@ def bind_server(server) -> None: global _bound_server _bound_server = server server._profile_execution_policy = _profile_execution_policy + server._revoke_peer_room_control = _revoke_peer_room_control def start_hosted_room_service(): @@ -144,6 +147,15 @@ def _api_server_key(profile: str | None = None) -> str: return (os.getenv("API_SERVER_KEY") or "").strip() +def _revoke_peer_room_control(room_id: str, member_id: str) -> int: + from gateway.hosted_room_control_client import revoke_stored_peer_control + from gateway.hosted_rooms import default_db_path + + return revoke_stored_peer_control( + default_db_path(), room_id=room_id, member_id=member_id + ) + + def _profile_execution_policy(profile: str) -> dict: """Resolve execution policy under the exact multiplexed profile home.""" @@ -252,14 +264,15 @@ def _(rid, params: dict) -> dict: "features": [ "authority_epoch", "coordinator_fencing", + "desktop_compatibility_mailbox", + "reciprocal_room_control", "room_identity", "monotonic_log", "idempotent_send", "replayable_disband", "typed_events", "actor_identity", - "log_replication", - "authority_takeover", + "peer_route_grant_fingerprint", ], "methods": [ "groups.capabilities", @@ -270,22 +283,101 @@ def _(rid, params: dict) -> dict: "groups.rename", "groups.log", "groups.disband", - "groups.replicate", "groups.replica_state", - "groups.promote", - "groups.demote", "groups.stop", "groups.retry", "groups.approve", "groups.peer.invite", + "groups.peer.revoke_exact", "groups.peer.revoke", "groups.peer.register", + "groups.desktop.claim", + "groups.desktop.presence", + "groups.desktop.renew", + "groups.desktop.complete", + "groups.control.invite", + "groups.control.register", + "groups.control.revoke", ], "max_log_limit": MAX_LOG_LIMIT, }, ) +@method("groups.desktop.claim") +def _(rid, params: dict) -> dict: + """Advertise classic rooms and lease pending messaging commands.""" + + try: + from gateway.desktop_room_mailbox import claim_commands, default_db_path + + commands = claim_commands( + default_db_path(), + consumer_id=params.get("consumer_id"), + room_authorities=params.get("room_authorities", []), + actions=params.get("actions"), + limit=params.get("limit", 8), + ) + return _ok(rid, {"commands": commands}) + except Exception as exc: + return _err(rid, 4130, str(exc)) + + +@method("groups.desktop.presence") +def _(rid, params: dict) -> dict: + """Renew classic-room ownership without claiming pending commands.""" + + try: + from gateway.desktop_room_mailbox import default_db_path, refresh_presence + + room_ids = refresh_presence( + default_db_path(), + consumer_id=params.get("consumer_id"), + room_authorities=params.get("room_authorities", []), + ) + return _ok(rid, {"room_ids": room_ids}) + except Exception as exc: + return _err(rid, 4137, str(exc)) + + +@method("groups.desktop.complete") +def _(rid, params: dict) -> dict: + """Commit the outcome of one classic-room compatibility command.""" + + try: + from gateway.desktop_room_mailbox import complete_command, default_db_path + + command = complete_command( + default_db_path(), + consumer_id=params.get("consumer_id"), + command_id=params.get("command_id"), + lease_token=params.get("lease_token"), + success=params.get("success") is True, + result=params.get("result", {}), + ) + return _ok(rid, {"command": command}) + except Exception as exc: + return _err(rid, 4131, str(exc)) + + +@method("groups.desktop.renew") +def _(rid, params: dict) -> dict: + """Renew one live classic-room command lease while its turn settles.""" + + try: + from gateway.desktop_room_mailbox import default_db_path, renew_command + + command = renew_command( + default_db_path(), + consumer_id=params.get("consumer_id"), + command_id=params.get("command_id"), + lease_token=params.get("lease_token"), + ) + return _ok(rid, {"command": command}) + except Exception as exc: + return _err(rid, 4132, str(exc)) + + @method("groups.peer.invite") def _(rid, params: dict) -> dict: """Mint one target-issued room/profile grant for a prospective home.""" @@ -363,6 +455,7 @@ def _(rid, params: dict) -> dict: gateway_room_grant_secret(), str(params.get("grant") or ""), permission="status", + allow_expired_for_revocation=True, ) if ( claims["target_profile"] != profile @@ -377,6 +470,41 @@ def _(rid, params: dict) -> dict: claims.get("status_expires_at", claims["expires_at"]) ), ) + _revoke_peer_room_control( + str(claims["room_id"]), str(claims["member_id"]) + ) + return _ok(rid, {"revoked": True}) + except Exception as exc: + return _err(rid, 4122, str(exc)) + + +@method("groups.peer.revoke_exact") +def _(rid, params: dict) -> dict: + """Revoke only this bearer grant, preserving concurrent replacements.""" + try: + from gateway import hosted_rooms + from gateway.hosted_room_peer import decode_room_grant, gateway_room_grant_secret + + profile = _requested_profile(params) + claims = decode_room_grant( + gateway_room_grant_secret(), + str(params.get("grant") or ""), + permission="status", + allow_expired_for_revocation=True, + ) + if ( + claims["target_profile"] != profile + or claims["target_install_id"] + != hosted_rooms.local_authority_gateway_id() + ): + raise ValueError("room grant target does not match this profile") + hosted_rooms.revoke_room_grant_id( + hosted_rooms.default_db_path(), + claims=claims, + expires_at=float( + claims.get("status_expires_at", claims["expires_at"]) + ), + ) return _ok(rid, {"revoked": True}) except Exception as exc: return _err(rid, 4122, str(exc)) @@ -410,6 +538,14 @@ def _(rid, params: dict) -> dict: raise ValueError("target does not support a direct RoomLink") target_profile = str(params.get("target_profile") or "") grant = str(params.get("grant") or "") + expected_grant_sha256 = None + if "expected_grant_sha256" in params: + expected_grant_sha256 = str(params.get("expected_grant_sha256") or "") + if expected_grant_sha256 and ( + len(expected_grant_sha256) != 64 + or any(character not in "0123456789abcdef" for character in expected_grant_sha256) + ): + raise ValueError("expected_grant_sha256 must be a sha256 digest") client = PeerRunsHTTPClient( base_url=target_url, api_key="", @@ -460,6 +596,11 @@ def _(rid, params: dict) -> dict: client=client, target_url=target_url, catalog=catalog, + **( + {"expected_grant_sha256": expected_grant_sha256} + if expected_grant_sha256 is not None + else {} + ), ) return _ok( rid, @@ -475,6 +616,166 @@ def _(rid, params: dict) -> dict: return _err(rid, 5120, str(exc)) +@method("groups.control.invite") +def _(rid, params: dict) -> dict: + """Issue one durable return-control credential to a room participant.""" + + try: + from gateway import hosted_room_controls + from gateway.hosted_room_peer import ( + PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION, + local_catalog_mapping, + ) + from gateway.hosted_rooms import local_authority_gateway_id, room_state + + service = get_hosted_room_service() + if service is None: + return _err(rid, 4123, _WORKER_UNAVAILABLE) + room = room_state(service.db_path, room_id=params.get("room_id")) + member_id = str(params.get("member_id") or "") + caller_install_id = str(params.get("caller_install_id") or "") + member = next( + ( + item + for item in room["members"] + if str(item.get("member_id") or "") == member_id + ), + None, + ) + target = member.get("target") if isinstance(member, dict) else None + if ( + not isinstance(target, dict) + or target.get("kind") != "peer" + or str(target.get("installation_id") or "") != caller_install_id + ): + raise ValueError("control participant does not match the frozen room member") + profile = _requested_profile(params) + catalog = local_catalog_mapping( + installation_id=local_authority_gateway_id(), + protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,), + link_modes=("direct",), + text=True, + attachments=False, + target_profile=profile, + execution_policy=_profile_execution_policy(profile), + ) + endpoint = catalog.get("endpoint") + home_url = ( + str(endpoint.get("url") or "") + if isinstance(endpoint, dict) and endpoint.get("available") is True + else "" + ) + if not home_url: + raise ValueError("room authority has no reachable control endpoint") + request_id = str(params.get("request_id") or "").strip() + if not request_id: + raise ValueError("room control invitation requires request_id") + now = time.time() + issued = hosted_room_controls.issue_home_control_token( + service.db_path, + room_id=room["room_id"], + member_id=member_id, + authority_gateway_id=room["authority_gateway_id"], + authority_epoch=int(room["authority_epoch"]), + expires_at=hosted_room_controls.ROOM_LIFETIME_EXPIRES_AT, + request_id=request_id, + now=now, + ) + return _ok( + rid, + { + "room_id": issued.room_id, + "member_id": issued.member_id, + "authority_gateway_id": issued.authority_gateway_id, + "authority_epoch": issued.authority_epoch, + "room_name": str(room.get("name") or room["room_id"]), + "member_count": len(room["members"]), + "control_token": issued.control_token, + "home_url": home_url, + "expires_at": issued.expires_at, + }, + ) + except Exception as exc: + return _err(rid, 4150, str(exc)) + + +@method("groups.control.register") +def _(rid, params: dict) -> dict: + """Persist one private return route on the participating gateway.""" + + try: + from gateway import hosted_room_controls + from gateway.hosted_room_control_client import RoomControlHTTPClient + from gateway.hosted_rooms import default_db_path + + profile = _requested_profile(params) + if not hosted_room_controls.peer_reservation_matches( + default_db_path(), + room_id=params.get("room_id"), + member_id=params.get("member_id"), + target_profile=profile, + authority_gateway_id=params.get("authority_gateway_id"), + authority_epoch=int(params.get("authority_epoch") or 0), + ): + raise ValueError("room control route has no matching live reservation") + saved = hosted_room_controls.save_peer_control_link( + default_db_path(), + room_id=params.get("room_id"), + member_id=params.get("member_id"), + home_url=params.get("home_url"), + authority_gateway_id=params.get("authority_gateway_id"), + authority_epoch=int(params.get("authority_epoch") or 0), + room_name=params.get("room_name"), + member_count=params.get("member_count"), + control_token=params.get("control_token"), + expires_at=params.get("expires_at"), + allow_rotation=True, + ) + try: + summary = RoomControlHTTPClient(saved.link).summary() + summary_room = summary.get("room") if isinstance(summary, dict) else None + if ( + not isinstance(summary_room, dict) + or str(summary_room.get("room_id") or "") != saved.link.room_id + or str(summary_room.get("authority_gateway_id") or "") + != saved.link.authority_gateway_id + or int(summary_room.get("authority_epoch") or 0) + != saved.link.authority_epoch + ): + raise ValueError("room control authority returned mismatched scope") + except Exception: + hosted_room_controls.delete_peer_control_links( + default_db_path(), + room_id=saved.link.room_id, + member_id=saved.link.member_id, + ) + raise + return _ok( + rid, + { + "registered": True, + "idempotent": saved.idempotent, + "room_id": saved.link.room_id, + "member_id": saved.link.member_id, + }, + ) + except Exception as exc: + return _err(rid, 4151, str(exc)) + + +@method("groups.control.revoke") +def _(rid, params: dict) -> dict: + """Revoke a participant's private return route idempotently.""" + + try: + room_id = str(params.get("room_id") or "") + member_id = str(params.get("member_id") or "") + removed = _revoke_peer_room_control(room_id, member_id) + return _ok(rid, {"revoked": removed}) + except Exception as exc: + return _err(rid, 4152, str(exc)) + + @method("groups.list") def _(rid, params: dict) -> dict: """List rooms hosted by this gateway.""" @@ -543,15 +844,16 @@ def _(rid, params: dict) -> dict: include_disbanded=params.get("include_disbanded") is True, ) service = get_hosted_room_service() + driver_status = ( + service.status_with_grant_fingerprints(str(room["room_id"])) + if service is not None and room.get("disbanded_at") is None + else None + ) return _ok( rid, { "room": room, - **( - {"driver_status": service.status(str(room["room_id"]))} - if service is not None and room.get("disbanded_at") is None - else {} - ), + **({"driver_status": driver_status} if driver_status else {}), }, ) except HostedRoomError as exc: @@ -635,7 +937,9 @@ def _(rid, params: dict) -> dict: if service is None: return _err(rid, 4123, _WORKER_UNAVAILABLE) - def disband_with_state(state: dict | None = None) -> dict: + def disband_with_controls(state: dict | None = None) -> dict: + from gateway import hosted_room_controls + local_gateway_id = local_authority_gateway_id() if state is not None and ( str(state["authority_gateway_id"]) != local_gateway_id @@ -643,7 +947,7 @@ def disband_with_state(state: dict | None = None) -> dict: raise AuthorityConflictError( "This Group Chat is managed by another gateway." ) - return disband_room( + tombstone = disband_room( service.db_path, room_id=params.get("room_id"), expected_gateway_id=str( @@ -653,6 +957,11 @@ def disband_with_state(state: dict | None = None) -> dict: state["authority_epoch"] if state is not None else 1 ), ) + hosted_room_controls.revoke_home_control_tokens( + service.db_path, + room_id=params.get("room_id"), + ) + return tombstone try: existing = room_state( @@ -661,10 +970,10 @@ def disband_with_state(state: dict | None = None) -> dict: include_disbanded=True, ) except RoomHistoryExpiredError: - tombstone = disband_with_state() + tombstone = disband_with_controls() return _ok(rid, {"tombstone": tombstone}) if existing.get("disbanded_at") is not None: - tombstone = disband_with_state(existing) + tombstone = disband_with_controls(existing) return _ok(rid, {"tombstone": tombstone}) service.stop_room( str(params.get("room_id") or ""), @@ -672,7 +981,7 @@ def disband_with_state(state: dict | None = None) -> dict: require_acknowledged=True, ) service.revoke_room_routes(str(params.get("room_id") or "")) - tombstone = disband_with_state(existing) + tombstone = disband_with_controls(existing) return _ok(rid, {"tombstone": tombstone}) except HostedRoomError as exc: reason = getattr(exc, "reason", None) @@ -729,6 +1038,7 @@ def _(rid, params: dict) -> dict: task = service.retry_room_task( str(params.get("room_id") or ""), task_id=str(params.get("task_id") or ""), + retry_id=str(params.get("command_id") or "") or None, ) identity = task.get("identity") if isinstance(task, dict) else None receipt = { @@ -772,28 +1082,13 @@ def _(rid, params: dict) -> dict: @method("groups.replicate") def _(rid, params: dict) -> dict: - """Persist one authority-stamped replay page into the local replica store. - - ``page`` is the verbatim ``groups.log`` result read from the room's - authority gateway; ingest is idempotent and refuses sequence gaps and - authority-epoch regressions. - """ - from gateway.hosted_room_replicas import ReplicaError, ingest_page - from gateway.hosted_rooms import default_db_path - - try: - result = ingest_page( - default_db_path(), - room_id=params.get("room_id"), - room_name=params.get("room_name"), - members=params.get("members"), - page=params.get("page"), - ) - return _ok(rid, result) - except ReplicaError as exc: - return _err(rid, 4116, str(exc)) - except Exception as exc: - return _err(rid, 5116, str(exc)) + """Fail closed until replica ingest is bound to verified RoomLink claims.""" + return _err( + rid, + 4116, + "Group Chat replication requires a verified RoomLink grant.", + {"reason": "replica_provenance_required"}, + ) @method("groups.replica_state") @@ -805,61 +1100,33 @@ def _(rid, params: dict) -> dict: try: return _ok(rid, replica_state(default_db_path(), room_id=params.get("room_id"))) except ReplicaError as exc: - return _err(rid, 4117, str(exc)) + reason = getattr(exc, "reason", None) + return _err(rid, 4117, str(exc), {"reason": reason} if reason else None) except Exception as exc: return _err(rid, 5117, str(exc)) @method("groups.promote") def _(rid, params: dict) -> dict: - """Continue a replicated room on THIS gateway at ``epoch + 1``. - - Requires ``confirm: true`` — the caller asserts the previous authority can - no longer commit (explicit user action; a lease/quorum driver later). - """ - from gateway.hosted_room_replicas import ReplicaError, promote_replica - from gateway.hosted_rooms import HostedRoomError, default_db_path - - if params.get("confirm") is not True: - return _err( - rid, - 4118, - "promotion requires confirm=true acknowledging the previous " - "authority can no longer commit", - ) - try: - result = promote_replica( - default_db_path(), - room_id=params.get("room_id"), - reason=params.get("reason", "authority-unreachable"), - ) - return _ok(rid, result) - except ReplicaError as exc: - return _err(rid, 4118, str(exc)) - except HostedRoomError as exc: - return _err(rid, 4118, str(exc)) - except Exception as exc: - return _err(rid, 5118, str(exc)) + """Fail closed for clients that cached the retired takeover method.""" + return _err( + rid, + 4118, + "Group Chat takeover is disabled until Hermes can select one globally " + "exclusive authority.", + {"reason": "authority_takeover_disabled"}, + ) @method("groups.demote") def _(rid, params: dict) -> dict: - """Fence this gateway's stale room authority against a proven newer epoch.""" - from gateway.hosted_room_replicas import ReplicaError, demote_room - from gateway.hosted_rooms import default_db_path - - try: - result = demote_room( - default_db_path(), - room_id=params.get("room_id"), - observed_gateway_id=params.get("observed_gateway_id"), - observed_epoch=params.get("observed_epoch"), - ) - return _ok(rid, result) - except ReplicaError as exc: - return _err(rid, 4119, str(exc)) - except Exception as exc: - return _err(rid, 5119, str(exc)) + """Fail closed for clients that cached the retired demotion method.""" + return _err( + rid, + 4119, + "Group Chat authority changes require a verified takeover decision.", + {"reason": "authority_takeover_disabled"}, + ) def register(server) -> None: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5f5c8249b46df..d968674f5d3cd 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5130,6 +5130,17 @@ def _bot_relay_outbox_sig(): return _bot_relay_outbox_seen or None +def _desktop_room_mailbox_sig(): + """mtime of commands written by a messaging process for Desktop rooms.""" + + home = _watcher_home() + root = home.parent.parent if home.parent.name == "profiles" else home + try: + return (root / "desktop_room_mailbox.pending").stat().st_mtime_ns + except OSError: + return None + + # Watched change signals: event → (check interval, signature fn, payload fn). # Signatures are stat/dict-lookup cheap, same bar as the skin watcher; the # check interval keeps the pricier probes (pet resolves the active sheet off @@ -5143,6 +5154,11 @@ def _bot_relay_outbox_sig(): # Cross-connection DM latency: 1s check so a queued envelope reaches the # Desktop's push-triggered drain fast; the Desktop's poll stays backstop. "bot_relay.outbox.pending": (1.0, _bot_relay_outbox_sig, lambda: {}), + "desktop_rooms.commands.pending": ( + 1.0, + _desktop_room_mailbox_sig, + lambda: {}, + ), } # state.db moves on every message append during a streaming turn, and the diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 5405230994df1..6b54dbef735b2 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -290,6 +290,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/memory [pending\|approve\|reject\|approval]` | Review pending memory writes staged by the write-approval gate (`memory.write_approval`) — approve or reject them right in chat — and toggle the gate with `/memory approval on\|off`. See [Controlling memory writes](/user-guide/features/memory#controlling-memory-writes-write_approval). | | `/skills [pending\|approve\|reject\|diff\|approval]` | Review pending **skill** writes staged by the write-approval gate (`skills.write_approval`). Shows a one-line gist per staged write; `/skills diff ` is truncated for chat — read the full diff on the CLI or in `~/.hermes/pending/skills/.json`. Only appears when the gate is on (or staged writes remain); search/install stay CLI-only. | | `/kanban ` | Drive the multi-profile, multi-project collaboration board from chat — identical argument surface to the CLI. Bypasses the running-agent guard, so `/kanban unblock t_abc`, `/kanban comment t_abc "…"`, `/kanban list --mine`, `/kanban boards switch `, etc. work mid-turn. `/kanban create …` auto-subscribes the originating chat to the new task's terminal events. See [Kanban slash command](/user-guide/features/kanban#kanban-slash-command). | +| `/group [list\| [bots\|bot @handle\|send \|retry\|stop]]` | **Messaging only.** Control Bot Group Chats from an authorized owner chat. Bare `/group` opens the recent-room picker where supported; `list` keeps every stable room number reachable; detail and `bots` are read-only; `send`, `retry`, and `stop` are durable, idempotent controls. The command dispatches without interrupting an active ordinary agent turn. See [Control Group Chats from mobile messaging](/user-guide/bot-mode#control-group-chats-from-mobile-messaging). | | `/platform [name]` | Operate a running gateway platform right from chat. `/platform list` shows every adapter and its state (running, paused-by-breaker, manually-paused); `/platform pause ` stops dispatching new messages to that adapter without unloading it; `/platform resume ` re-enables it and clears a tripped circuit breaker once the upstream is healthy. | | `/reload-mcp` (alias: `/reload_mcp`) | Reload MCP servers from config. | | `/verbose` | Cycle tool progress display. **Off by default on messaging** — enable with `display.tool_progress_command: true` in `config.yaml`. | @@ -309,7 +310,7 @@ The messaging gateway supports the following built-in commands inside Telegram, - `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/focus` and `/verbose` share one suppression path (`display.tool_progress`), so they can never contradict each other: `/focus on` pins tool progress to `off` and stashes your mode under `display.focus_saved_tool_progress`; `/focus off` restores it; cycling `/verbose` while focus is on takes the mode back and clears the focus badge. Focus view is display-only — it never changes conversation history, the system prompt, or anything sent to the model, so it has zero prompt-cache impact. -- `/sethome`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands. +- `/sethome`, `/restart`, `/approve`, `/deny`, `/topic`, `/group`, `/platform`, and `/commands` are **messaging-only** commands. - `/status`, `/egress`, `/version`, `/whoami`, `/bg`, `/btw`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/approvals`, `/busy`, `/footer`, `/curator`, `/kanban`, `/topup`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. - In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume ` for saved or closed transcripts. diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 0e50502a3f0b3..7bed08db1f241 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -125,6 +125,42 @@ Every gateway you register in **Settings → Connections** — local, remote URL - **`message_agent` reaches them directly.** A Bot on your laptop messages the cloud agent with `message_agent(target="moxie", …)` exactly like a local teammate. If the same handle exists on several machines, disambiguate with `target="moxie@"` (the tool's error tells the Bot the exact forms). Delivery rides the Desktop: the sending gateway queues the message, the Desktop relays it to the target connection's own gateway, the target Bot runs a turn in its canonical Bot Chat, and the reply comes back to the sender as the same background completion notification local DMs use. - **The Desktop is the courier.** Cross-connection delivery works while a Desktop that knows both connections is running (it holds the sockets and the credentials — gateways never see each other's auth). If the Desktop is closed mid-delivery, the sender's Bot is told the reply didn't arrive rather than left hanging. For always-on machine-to-machine messaging with no Desktop in the loop, register a peer (`hermes peer`, below) — the two routes coexist. +### Control Group Chats from mobile messaging + +From an authorized private owner chat on a connected messaging platform, use +`/group` to open the same Bot Group Chats without keeping Hermes Desktop in the +foreground: + +```text +/group +/group 2 +/group 2 bots +/group 2 bot @handle +/group 2 send Review the launch checklist +/group 2 retry +/group 2 stop +``` + +Telegram, Discord, and Matrix can show native room and participant pickers. +Other messaging clients receive the same controls as bounded text. Group Chat +numbers stay stable for the lifetime of the room and are not reused after +Disband. + +These commands are gateway controls, not ordinary agent prompts. A Send is +recorded durably and acknowledged as queued or saved; the Group Chat driver runs +Bot turns separately. Sending `/group ...` while your ordinary Hermes chat is +working does not interrupt that turn or wait on its conversation lock. + +Hosted Group Chats continue while Desktop is closed as long as their authority +gateway and required Bot routes remain reachable. Classic compatibility rooms +save commands for the exact owning Desktop and say so explicitly until it is +online. Stop is two-phase: `Stop requested` means cancellation is in progress, +not that every active Bot turn has already terminated. + +This control surface is owner-only by default. It does not bind arbitrary public +channels to a Group Chat, mirror every room message into a native channel, or +accept bot/webhook-authored control traffic. + ### Bot-initiated DMs across machines (`hermes peer`) Bots on one machine can message Bots on **another machine's gateway** without any desktop in the loop. Register the other gateway as a *peer* (its API server URL + `API_SERVER_KEY`): From 1066a83291e04362fc5f77e5ba9344765c11ddb7 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:22:19 +0200 Subject: [PATCH 06/16] feat(bot-mode): control Group Chats from messaging Recompose the reviewed Messaging contract on the current Desktop continuity head. --- .../src/plugins/hermes-bots/create-dialog.tsx | 9 + .../desktop-room-command-client.test.ts | 253 +++ .../desktop-room-command-client.ts | 268 +++ .../desktop-room-command-runtime.test.ts | 418 ++++ .../desktop-room-command-runtime.ts | 798 +++++++ .../plugins/hermes-bots/group-chat.test.ts | 20 + .../src/plugins/hermes-bots/group-chat.ts | 56 +- .../group-continuity-creation.test.tsx | 14 + .../plugins/hermes-bots/group-membership.ts | 21 +- .../src/plugins/hermes-bots/group-rounds.ts | 99 +- .../hermes-bots/hosted-room-client.test.ts | 3 +- .../plugins/hermes-bots/hosted-room-client.ts | 9 + .../hermes-bots/hosted-room-runtime.test.ts | 269 ++- .../hermes-bots/hosted-room-runtime.ts | 232 +- .../src/plugins/hermes-bots/plugin.tsx | 15 + apps/desktop/src/plugins/hermes-bots/types.ts | 10 + docs/relay-connector-contract.md | 10 +- gateway/authz_mixin.py | 122 +- gateway/desktop_room_mailbox.py | 1097 +++++++++ gateway/group_chat_slash.py | 762 +++++++ gateway/hosted_room_control_client.py | 170 ++ gateway/hosted_room_controls.py | 1325 +++++++++++ gateway/hosted_room_driver.py | 237 +- gateway/hosted_room_messaging.py | 1989 +++++++++++++++++ gateway/hosted_room_messaging_approvals.py | 1062 +++++++++ gateway/hosted_room_policy_checkpoint.py | 29 +- gateway/hosted_room_storage.py | 3 + gateway/platforms/api_server_room_controls.py | 368 +++ gateway/platforms/api_server_room_grants.py | 25 +- gateway/platforms/signal.py | 3 + gateway/platforms/whatsapp_common.py | 1 - gateway/relay/ws_transport.py | 23 + gateway/run.py | 7 + gateway/session.py | 6 + gateway/slash_access.py | 52 + gateway/slash_commands.py | 18 +- hermes_cli/commands.py | 6 +- plugins/platforms/discord/adapter.py | 37 +- plugins/platforms/matrix/adapter.py | 8 + plugins/platforms/mattermost/adapter.py | 1 + plugins/platforms/slack/adapter.py | 10 +- plugins/platforms/telegram/adapter.py | 25 +- plugins/platforms/whatsapp/adapter.py | 12 +- .../test_api_server_room_controls.py | 248 ++ tests/gateway/test_api_server_room_grants.py | 62 + .../test_api_server_runs_extraction.py | 6 +- tests/gateway/test_desktop_room_mailbox.py | 818 +++++++ tests/gateway/test_discord_component_auth.py | 66 +- tests/gateway/test_discord_slash_commands.py | 41 +- .../gateway/test_group_chat_matrix_adapter.py | 84 + .../gateway/test_group_chat_slack_adapter.py | 112 + .../test_hosted_room_control_client.py | 161 ++ tests/gateway/test_hosted_room_controls.py | 637 ++++++ tests/gateway/test_hosted_room_driver.py | 181 ++ tests/gateway/test_hosted_room_messaging.py | 1740 ++++++++++++++ .../test_hosted_room_messaging_approvals.py | 710 ++++++ .../test_hosted_room_messaging_security.py | 969 ++++++++ tests/gateway/test_hosted_room_peer.py | 39 + tests/gateway/test_hosted_rooms.py | 74 +- tests/gateway/test_signal.py | 26 + tests/gateway/test_signal_format.py | 2 - tests/gateway/test_slash_access.py | 93 +- tests/gateway/test_telegram_choice_picker.py | 143 ++ tests/gateway/test_telegram_format.py | 1 - tests/gateway/test_telegram_reply_quote.py | 13 + tests/gateway/test_whatsapp_formatting.py | 9 +- tests/gateway/test_whatsapp_from_owner.py | 3 +- tests/tui_gateway/test_change_watcher.py | 11 + tests/tui_gateway/test_groups_methods.py | 221 +- .../test_hosted_room_messaging_approvals.py | 811 +++++++ .../test_hosted_room_messaging_retry.py | 325 +++ tests/tui_gateway/test_hosted_room_service.py | 206 +- tui_gateway/hosted_room_driver.py | 73 +- tui_gateway/hosted_room_service.py | 529 ++++- tui_gateway/methods_groups.py | 278 ++- tui_gateway/server.py | 16 + 76 files changed, 18192 insertions(+), 418 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts create mode 100644 gateway/desktop_room_mailbox.py create mode 100644 gateway/group_chat_slash.py create mode 100644 gateway/hosted_room_control_client.py create mode 100644 gateway/hosted_room_controls.py create mode 100644 gateway/hosted_room_messaging.py create mode 100644 gateway/hosted_room_messaging_approvals.py create mode 100644 gateway/platforms/api_server_room_controls.py create mode 100644 tests/gateway/platforms/test_api_server_room_controls.py create mode 100644 tests/gateway/test_desktop_room_mailbox.py create mode 100644 tests/gateway/test_group_chat_matrix_adapter.py create mode 100644 tests/gateway/test_group_chat_slack_adapter.py create mode 100644 tests/gateway/test_hosted_room_control_client.py create mode 100644 tests/gateway/test_hosted_room_controls.py create mode 100644 tests/gateway/test_hosted_room_messaging.py create mode 100644 tests/gateway/test_hosted_room_messaging_approvals.py create mode 100644 tests/gateway/test_hosted_room_messaging_security.py create mode 100644 tests/gateway/test_telegram_choice_picker.py create mode 100644 tests/tui_gateway/test_hosted_room_messaging_approvals.py create mode 100644 tests/tui_gateway/test_hosted_room_messaging_retry.py diff --git a/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx b/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx index 7688a8109a29c..52c460ba48b40 100644 --- a/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx +++ b/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx @@ -44,6 +44,7 @@ import { AvatarPicker } from './avatar-picker' import { $selectedBot } from './bot-state' import { createCanonicalChat } from './canonical-chat' import { $botMeta, botHandle, botRosterKey, filterBots, ROSTER_KEY, saveBotMeta } from './data' +import { prepareDesktopRoomAuthority } from './desktop-room-command-runtime' import { labeled, ResizableFrame } from './dialog-parts' import { $groupChats, @@ -1341,6 +1342,8 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea } } + const desktopAuthority = hosted ? null : await prepareDesktopRoomAuthority() + for (const owner of metadataOwners) { await saveBotMeta(owner, groupMembershipPatch(botRosterMeta(owner, allMeta), groupName, true)) } @@ -1353,6 +1356,12 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea room.roomId = roomId room.continuityMode = hosted?.continuityMode || 'desktop' + if (desktopAuthority) { + room.desktopAuthorityHash = desktopAuthority.desktopAuthorityHash + room.desktopAuthorityToken = desktopAuthority.desktopAuthorityToken + room.desktopCoordinatorId = desktopAuthority.desktopCoordinatorId + } + if (hosted) { room.hosted = hosted.authorityId room.hostedEpoch = hosted.authorityEpoch diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts new file mode 100644 index 0000000000000..c0474ae33c6cc --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + desktopRoomDescriptors, + desktopRoomIdentity, + runDesktopRoomCommandCycle +} from './desktop-room-command-client' +import type { GroupChat, ProfileRoute } from './types' + +const route = (connectionId: string): ProfileRoute => ({ + connectionId, + mode: 'remote', + profile: 'default', + targetProfile: 'default' +}) + +const classic = (overrides: Partial = {}): GroupChat => ({ + desktopAuthorityToken: 'authority:test', + log: [], + roomId: 'room-1', + watermarks: {}, + ...overrides +}) + +describe('classic Group Chat command client', () => { + it('advertises only classic rooms with local authority tokens', () => { + const rooms = { + Classic: classic(), + Legacy: classic({ roomId: null }), + Hosted: classic({ hosted: 'gateway-a' }), + Deleted: classic({ tombstone: true }) + } + + expect(desktopRoomIdentity('Legacy', rooms.Legacy)).toBe('name:Legacy') + expect(desktopRoomDescriptors(rooms)).toEqual([ + { + authorityToken: 'authority:test', + name: 'Classic', + roomId: 'room-1' + }, + { + authorityToken: 'authority:test', + name: 'Legacy', + roomId: 'name:Legacy' + } + ]) + }) + + it('claims, executes, and completes once per gateway', async () => { + const calls: Array<{ connectionId: string; method: string; params: Record }> = [] + + const request = vi.fn(async (target: ProfileRoute, method: string, params: Record) => { + calls.push({ + connectionId: target.connectionId, + method, + params + }) + + if (target.connectionId === 'old') { + throw new Error('method not found') + } + + return method === 'groups.desktop.claim' + ? { + commands: [ + { + action: 'send', + command_id: 'messaging:1', + payload: { message: 'hello' }, + room_id: 'room-1' + } + ] + } + : {} + }) + + const outcomes = await runDesktopRoomCommandCycle({ + consumerId: 'desktop:test', + execute: async command => ({ + thread_id: `thread:${command.command_id}` + }), + request, + rooms: { + Classic: classic() + }, + routes: [route('old'), route('current'), route('current')] + }) + + expect(outcomes).toEqual([ + { + commandId: 'messaging:1', + connectionId: 'current', + success: true + } + ]) + expect(calls.filter(call => call.method === 'groups.desktop.claim').map(call => call.connectionId)).toEqual([ + 'old', + 'current' + ]) + expect(calls.find(call => call.method === 'groups.desktop.complete')?.params).toMatchObject({ + result: { + thread_id: 'thread:messaging:1' + }, + success: true + }) + }) + + it('reads attachments through the gateway that issued the claim', async () => { + const calls: string[] = [] + + await runDesktopRoomCommandCycle({ + consumerId: 'desktop:test', + execute: async (_command, _rooms, context) => { + await context.request('groups.attachment.read', { + attachment_id: 'att_1' + }) + + return { + settled: true + } + }, + request: async (target, method) => { + calls.push(`${target.connectionId}:${method}`) + + return method === 'groups.desktop.claim' + ? { + commands: [ + { + action: 'send', + command_id: 'messaging:file', + lease_token: 'lease:one', + room_id: 'room-1' + } + ] + } + : {} + }, + rooms: { + Classic: classic() + }, + routes: [route('gateway-b')] + }) + + expect(calls).toContain('gateway-b:groups.attachment.read') + }) + + it('leaves retryable work unacknowledged', async () => { + const methods: string[] = [] + + const outcomes = await runDesktopRoomCommandCycle({ + consumerId: 'desktop:test', + execute: async () => { + throw Object.assign(new Error('member offline'), { + retryable: true + }) + }, + request: async (_target, method) => { + methods.push(method) + + return method === 'groups.desktop.claim' + ? { + commands: [ + { + command_id: 'messaging:later', + room_id: 'room-1' + } + ] + } + : {} + }, + rooms: { + Classic: classic() + }, + routes: [route('current')] + }) + + expect(methods).toEqual(['groups.desktop.claim']) + expect(outcomes).toEqual([ + { + commandId: 'messaging:later', + connectionId: 'current', + retryable: true, + success: false + } + ]) + }) + + it('bounds large room claims', async () => { + const claimSizes: number[] = [] + + const rooms = Object.fromEntries( + Array.from({ length: 260 }, (_, index) => [ + `Room ${index}`, + classic({ + desktopAuthorityToken: `authority:${index}`, + roomId: `room-${index}` + }) + ]) + ) + + await runDesktopRoomCommandCycle({ + consumerId: 'desktop:test', + execute: async () => ({}), + request: async (_target, method, params) => { + if (method === 'groups.desktop.claim') { + claimSizes.push((params.room_authorities as unknown[]).length) + } + + return { + commands: [] + } + }, + rooms, + routes: [route('current')] + }) + + expect(claimSizes).toEqual([128, 128, 4]) + }) + + it('keeps the unscoped local gateway compatibility path', async () => { + const calls: Array<{ method: string; params: Record }> = [] + + await runDesktopRoomCommandCycle({ + consumerId: 'desktop:test', + execute: async () => ({}), + request: async (_target, method, params) => { + calls.push({ method, params }) + + return { + commands: [] + } + }, + rooms: { + Local: classic({ + roomId: 'room-local' + }) + }, + routes: [route('')] + }) + + expect(calls[0]).toMatchObject({ + method: 'groups.desktop.claim', + params: { + room_authorities: [ + { + authority_token: 'authority:test', + room_id: 'room-local' + } + ] + } + }) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts new file mode 100644 index 0000000000000..6ffede5313ebe --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts @@ -0,0 +1,268 @@ +import type { GroupChat, ProfileRoute } from './types' + +const MAX_COMMANDS_PER_CLAIM = 8 +const MAX_COMMANDS_PER_WAKE = 64 +const MAX_ROOM_IDS_PER_CLAIM = 128 +const LEASE_RENEW_INTERVAL_MS = 15_000 + +export interface DesktopRoomDescriptor { + authorityToken: string + name: string + roomId: string +} + +export interface DesktopRoomCommand { + action?: string + command_id?: string + lease_token?: string + payload?: Record + room_id?: string + target_command_state?: string + target_result_code?: string +} + +interface CommandExecutionContext { + consumerId: string + request: (method: string, params: Record) => Promise + route: ProfileRoute + signal: AbortSignal | null +} + +interface RunDesktopRoomCommandCycleInput { + actions?: string[] | null + consumerId: string + execute: ( + command: DesktopRoomCommand, + rooms: DesktopRoomDescriptor[], + context: CommandExecutionContext + ) => Promise + request: (route: ProfileRoute, method: string, params: Record) => Promise + rooms: Record + routes: ProfileRoute[] + shouldContinue?: () => boolean +} + +export interface DesktopRoomCommandOutcome { + commandId: string + connectionId: string + leaseLost?: boolean + retryable?: boolean + success: boolean +} + +/** Stable identity shared by the bounded gateway projection and command queue. */ +export function desktopRoomIdentity(name: string, room: GroupChat) { + const roomId = String(room?.roomId || '').trim() + + return roomId || `name:${String(name || '').trim()}` +} + +/** Classic rooms this Desktop can coordinate. Hosted rooms run on a gateway. */ +export function desktopRoomDescriptors(rooms: Record): DesktopRoomDescriptor[] { + return Object.entries(rooms || {}) + .filter(([, room]) => { + const hosted = typeof room?.hosted === 'string' ? room.hosted.trim() : '' + + return !hosted && !room?.tombstone && Array.isArray(room?.log) + }) + .map(([name, room]) => ({ + name, + roomId: desktopRoomIdentity(name, room), + authorityToken: String(room?.desktopAuthorityToken || '').trim() + })) + .filter(room => room.roomId && room.name && room.authorityToken) +} + +export function createDesktopRoomConsumerId() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { + return `desktop:${globalThis.crypto.randomUUID()}` + } + + return `desktop:${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + +function boundedError(error: unknown) { + const text = String(error instanceof Error ? error.message : 'Desktop could not apply the Group Chat command') + .replace(/\s+/g, ' ') + .trim() + + return text.slice(0, 240) +} + +/** Claim and apply classic-room commands from every reachable default gateway. + * Missing methods identify an older backend and leave its queue untouched. */ +export async function runDesktopRoomCommandCycle({ + routes, + consumerId, + rooms, + request, + execute, + actions = null, + shouldContinue = () => true +}: RunDesktopRoomCommandCycleInput): Promise { + const descriptors = desktopRoomDescriptors(rooms) + + if (!descriptors.length) { + return [] + } + + const roomBatches: DesktopRoomDescriptor[][] = [] + + for (let index = 0; index < descriptors.length; index += MAX_ROOM_IDS_PER_CLAIM) { + roomBatches.push(descriptors.slice(index, index + MAX_ROOM_IDS_PER_CLAIM)) + } + + const seenConnections = new Set() + const outcomes: DesktopRoomCommandOutcome[] = [] + + for (const route of Array.isArray(routes) ? routes : []) { + const connectionId = String(route?.connectionId || '') + const routeKey = connectionId || '__active__' + + if (seenConnections.has(routeKey)) { + continue + } + + seenConnections.add(routeKey) + let remaining = MAX_COMMANDS_PER_WAKE + + for (const batch of roomBatches) { + if (remaining <= 0) { + return outcomes + } + + const roomAuthorities = batch.map(room => ({ + room_id: room.roomId, + authority_token: room.authorityToken + })) + + while (remaining > 0) { + const claimLimit = Math.min(MAX_COMMANDS_PER_CLAIM, remaining) + let claimed: unknown + + try { + claimed = await request(route, 'groups.desktop.claim', { + consumer_id: consumerId, + room_authorities: roomAuthorities, + ...(Array.isArray(actions) && actions.length + ? { + actions + } + : {}), + limit: claimLimit + }) + } catch { + break + } + + const commands = Array.isArray((claimed as { commands?: unknown[] } | null)?.commands) + ? ((claimed as { commands: DesktopRoomCommand[] }).commands || []) + : [] + + remaining -= commands.length + + for (const command of commands) { + if (!shouldContinue()) { + return outcomes + } + + let success = false + let result: unknown + let renewTimer: ReturnType | null = null + let leaseLost = false + const abortController = typeof AbortController === 'function' ? new AbortController() : null + const leaseToken = String(command?.lease_token || '') + + if (leaseToken && typeof setInterval === 'function') { + renewTimer = setInterval(() => { + if (!shouldContinue()) { + return + } + + void request(route, 'groups.desktop.renew', { + consumer_id: consumerId, + command_id: command.command_id, + lease_token: leaseToken + }).catch(() => { + leaseLost = true + abortController?.abort('lease-lost') + }) + }, LEASE_RENEW_INTERVAL_MS) + } + + try { + result = await execute(command, descriptors, { + signal: abortController?.signal || null, + route, + consumerId, + request: (method, params) => request(route, method, params) + }) + + if (leaseLost) { + outcomes.push({ + commandId: String(command.command_id || ''), + connectionId: routeKey, + success: false, + retryable: true, + leaseLost: true + }) + + continue + } + + success = true + } catch (error) { + if (leaseLost || (error as { retryable?: boolean } | null)?.retryable === true) { + outcomes.push({ + commandId: String(command.command_id || ''), + connectionId: routeKey, + success: false, + retryable: true, + ...(leaseLost + ? { + leaseLost: true + } + : {}) + }) + + continue + } + + result = { + message: boundedError(error) + } + } finally { + if (renewTimer !== null && typeof clearInterval === 'function') { + clearInterval(renewTimer) + } + } + + try { + await request(route, 'groups.desktop.complete', { + consumer_id: consumerId, + command_id: command.command_id, + lease_token: leaseToken, + success, + result + }) + } catch { + // The lease expires and retries the same command id. Room effects + // are idempotent, so a lost completion ACK cannot duplicate text. + } + + outcomes.push({ + commandId: String(command.command_id || ''), + connectionId: routeKey, + success + }) + } + + if (commands.length < claimLimit) { + break + } + } + } + } + + return outcomes +} diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts new file mode 100644 index 0000000000000..97f1458e4a2a8 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts @@ -0,0 +1,418 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { pluginSdkMock, scriptedStorage } from './group-test-utils' + +const { host } = vi.hoisted(() => ({ + host: {} as Record +})) + +const groupRounds = vi.hoisted(() => ({ + cancelGroupThreadForLeaseLoss: vi.fn(async (..._args: unknown[]) => undefined), + sendToGroupChat: vi.fn((..._args: unknown[]): unknown => null), + stopGroupThread: vi.fn(async (..._args: unknown[]) => undefined) +})) + +vi.mock('@hermes/plugin-sdk', async () => pluginSdkMock(host)) +vi.mock('./group-rounds', () => groupRounds) + +async function loadRuntime() { + vi.resetModules() + + for (const key of Object.keys(host)) { + delete host[key] + } + + Object.assign(host, { + activeConnectionId: () => 'gateway-a', + onEvent: vi.fn(() => () => undefined), + profileRoutes: async () => [], + request: vi.fn(async () => ({})), + requestProfile: vi.fn(async () => ({})), + retainProfileSocket: vi.fn(() => () => undefined), + state: { + connectionId: { + get: () => 'gateway-a', + listen: () => () => undefined + } + } + }) + + const [chat, data, runtime] = await Promise.all([ + import('./group-chat'), + import('./data'), + import('./desktop-room-command-runtime') + ]) + + return { + chat, + data, + runtime + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.clearAllMocks() + vi.clearAllTimers() + vi.useRealTimers() +}) + +describe('classic Group Chat command runtime', () => { + it('mints one private authority and publishes only its hash', async () => { + const loaded = await loadRuntime() + const stored = new Map() + + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + const authority = await loaded.runtime.prepareDesktopRoomAuthority() + + expect(authority.desktopCoordinatorId).toMatch(/^desktop:/) + expect(authority.desktopAuthorityToken).toMatch(/^authority:/) + expect(authority.desktopAuthorityHash).toMatch(/^[a-f0-9]{64}$/) + expect(authority.desktopAuthorityHash).not.toContain(authority.desktopAuthorityToken) + expect(stored.get('desktop-room-command-consumer-v1')).toBe(authority.desktopCoordinatorId) + + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('does not cache a coordinator identity until storage confirms it', async () => { + const loaded = await loadRuntime() + const stored = new Map() + let failRead = true + let failWrite = true + const storage = { + get: vi.fn(async (key: string) => { + if (failRead) { + throw new Error('read unavailable') + } + return stored.get(key) ?? null + }), + set: vi.fn(async (key: string, value: unknown) => { + if (failWrite) { + throw new Error('disk unavailable') + } + stored.set(key, structuredClone(value)) + }) + } + + await loaded.runtime.startDesktopRoomCommandRuntime(storage as never) + await expect(loaded.runtime.prepareDesktopRoomAuthority()).rejects.toThrow('could not read') + expect(storage.set).not.toHaveBeenCalled() + + failRead = false + await expect(loaded.runtime.prepareDesktopRoomAuthority()).rejects.toThrow('disk unavailable') + + failWrite = false + const authority = await loaded.runtime.prepareDesktopRoomAuthority() + expect(stored.get('desktop-room-command-consumer-v1')).toBe(authority.desktopCoordinatorId) + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('adopts only a classic room with local execution evidence', async () => { + const loaded = await loadRuntime() + const stored = new Map() + + loaded.chat.$groupChats.set({ + Active: { + log: [], + sessions: { + research: 'session-1' + }, + watermarks: {} + }, + Silent: { + log: [], + watermarks: {} + }, + Hosted: { + hosted: 'install:home', + log: [], + sessions: { + research: 'session-2' + }, + watermarks: {} + } + }) + + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + + expect(loaded.chat.$groupChats.get().Active).toMatchObject({ + desktopAuthorityHash: expect.stringMatching(/^[a-f0-9]{64}$/), + desktopAuthorityToken: expect.stringMatching(/^authority:/), + desktopCoordinatorId: expect.stringMatching(/^desktop:/), + roomId: expect.stringMatching(/^[a-z0-9-]+$/) + }) + expect(loaded.chat.$groupChats.get().Silent.desktopCoordinatorId).toBeUndefined() + expect(loaded.chat.$groupChats.get().Hosted.desktopCoordinatorId).toBeUndefined() + + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('lets healthy Bots continue when another Group Chat member is offline', async () => { + const loaded = await loadRuntime() + const stored = new Map() + + const members = [ + { connectionId: 'gateway-a', name: 'online' }, + { connectionId: 'gateway-b', name: 'offline', sourceMissing: true } + ] + + loaded.data.$lastRoster.set(members) + loaded.chat.$groupChats.set({ + Planning: { + log: [], + members, + roomId: 'room-1', + sessions: {}, + watermarks: {} + } + }) + groupRounds.sendToGroupChat.mockImplementation((...args: unknown[]) => { + const options = (args[5] || {}) as { entryId?: unknown } + const room = loaded.chat.$groupChats.get().Planning + loaded.chat.$groupChats.set({ + Planning: { + ...room, + desktopCommandSettled: { + [String(options.entryId)]: Date.now() + } + } + }) + + return 'thread-1' + }) + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + + const result = await loaded.runtime.executeDesktopRoomCommand( + { + action: 'send', + command_id: 'messaging:send-1', + payload: { + message: 'Review the plan', + recipients: members + }, + room_id: 'room-1' + }, + [{ authorityToken: 'authority:test', name: 'Planning', roomId: 'room-1' }], + { + consumerId: 'desktop:test', + request: vi.fn(async () => ({})), + route: { connectionId: 'gateway-a', mode: 'remote', profile: 'default', targetProfile: 'default' }, + signal: null + } + ) + + expect(result).toEqual({ room_name: 'Planning', thread_id: 'thread-1' }) + expect(groupRounds.sendToGroupChat).toHaveBeenCalledWith( + 'Planning', + expect.arrayContaining([ + expect.objectContaining({ name: 'online' }), + expect.objectContaining({ name: 'offline' }) + ]), + 'Review the plan', + null, + undefined, + expect.objectContaining({ entryId: 'messaging:send-1' }) + ) + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('settles a durable Stop after restart when its send was already superseded', async () => { + const loaded = await loadRuntime() + const stored = new Map() + const members = [{ connectionId: 'gateway-a', name: 'online' }] + + loaded.data.$lastRoster.set(members) + loaded.chat.$groupChats.set({ + Planning: { + log: [], + members, + roomId: 'room-1', + sessions: {}, + watermarks: {} + } + }) + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + + const result = await loaded.runtime.executeDesktopRoomCommand( + { + action: 'stop', + command_id: 'messaging:stop-1', + payload: { target_command_id: 'messaging:send-1' }, + room_id: 'room-1', + target_command_state: 'failed', + target_result_code: 'superseded_by_stop' + }, + [{ authorityToken: 'authority:test', name: 'Planning', roomId: 'room-1' }], + { + consumerId: 'desktop:test', + request: vi.fn(async () => ({})), + route: { connectionId: 'gateway-a', mode: 'remote', profile: 'default', targetProfile: 'default' }, + signal: null + } + ) + + expect(result).toEqual({ room_name: 'Planning', stale: true, stopped: true }) + expect(groupRounds.stopGroupThread).not.toHaveBeenCalled() + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('still aborts live work when the mailbox already marked its send superseded', async () => { + const loaded = await loadRuntime() + const stored = new Map() + const members = [{ connectionId: 'gateway-a', name: 'online' }] + + loaded.data.$lastRoster.set(members) + loaded.chat.$groupChats.set({ + Planning: { + log: [], + members, + roomId: 'room-1', + sessions: {}, + watermarks: {} + } + }) + groupRounds.sendToGroupChat.mockImplementation((...args: unknown[]) => { + const group = String(args[0] || '') + const room = loaded.chat.$groupChats.get()[group] + loaded.chat.$groupChats.set({ + [group]: { + ...room, + running: true + } + }) + + return 'thread-1' + }) + groupRounds.stopGroupThread.mockImplementation(async (...args: unknown[]) => { + const group = String(args[0] || '') + const room = loaded.chat.$groupChats.get()[group] + loaded.chat.$groupChats.set({ + [group]: { + ...room, + running: false + } + }) + }) + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + + const context = { + consumerId: 'desktop:test', + request: vi.fn(async () => ({})), + route: { connectionId: 'gateway-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + signal: null + } + + const descriptors = [{ authorityToken: 'authority:test', name: 'Planning', roomId: 'room-1' }] + + const send = loaded.runtime.executeDesktopRoomCommand( + { + action: 'send', + command_id: 'messaging:send-1', + payload: { message: 'Review the plan', recipients: members }, + room_id: 'room-1' + }, + descriptors, + context + ) + + await Promise.resolve() + await Promise.resolve() + + const stopped = await loaded.runtime.executeDesktopRoomCommand( + { + action: 'stop', + command_id: 'messaging:stop-1', + payload: { target_command_id: 'messaging:send-1' }, + room_id: 'room-1', + target_command_state: 'failed', + target_result_code: 'superseded_by_stop' + }, + descriptors, + context + ) + + expect(stopped).toEqual({ room_name: 'Planning', stopped: true }) + expect(groupRounds.stopGroupThread).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(250) + await expect(send).resolves.toEqual({ room_name: 'Planning', stopped: true }) + + groupRounds.stopGroupThread.mockClear() + loaded.chat.$groupChats.set({ + Planning: { + ...loaded.chat.$groupChats.get().Planning, + desktopCommandSettled: {}, + log: [ + { + at: 1, + from: { kind: 'user', name: 'You' }, + id: 'old-message', + text: 'Earlier work', + thread: 'thread-old' + } + ], + running: false + } + }) + groupRounds.sendToGroupChat.mockImplementation(() => { + const room = loaded.chat.$groupChats.get().Planning + loaded.chat.$groupChats.set({ Planning: { ...room, running: true } }) + return 'thread-new' + }) + groupRounds.stopGroupThread.mockResolvedValue(undefined) + + const laterSend = loaded.runtime.executeDesktopRoomCommand( + { + action: 'send', + command_id: 'messaging:send-later', + payload: { message: 'New work', recipients: members }, + room_id: 'room-1' + }, + descriptors, + context + ) + await Promise.resolve() + await Promise.resolve() + const earlierStop = await loaded.runtime.executeDesktopRoomCommand( + { + action: 'stop', + command_id: 'messaging:stop-earlier', + payload: { target_thread_id: 'thread-old' }, + room_id: 'room-1' + }, + descriptors, + context + ) + + expect(earlierStop).toEqual({ room_name: 'Planning', stopped: true }) + expect(groupRounds.stopGroupThread).toHaveBeenCalledWith('Planning', 'thread-old', expect.any(Array)) + loaded.chat.updateGroupChat('Planning', current => ({ + ...current, + desktopCommandSettled: { 'messaging:send-later': Date.now() }, + running: false + })) + await vi.advanceTimersByTimeAsync(250) + await expect(laterSend).resolves.toEqual({ room_name: 'Planning', thread_id: 'thread-new' }) + + groupRounds.cancelGroupThreadForLeaseLoss.mockClear() + const abandonedSend = loaded.runtime.executeDesktopRoomCommand( + { + action: 'send', + command_id: 'messaging:send-disposed', + payload: { message: 'Work during reload', recipients: members }, + room_id: 'room-1' + }, + descriptors, + context + ) + const abandonedExpectation = expect(abandonedSend).rejects.toThrow('moved to another Desktop') + await Promise.resolve() + await Promise.resolve() + loaded.runtime.stopDesktopRoomCommandRuntime() + await vi.advanceTimersByTimeAsync(250) + await abandonedExpectation + expect(groupRounds.cancelGroupThreadForLeaseLoss).toHaveBeenCalledWith('Planning', members) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts new file mode 100644 index 0000000000000..fa379720fbb0a --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts @@ -0,0 +1,798 @@ +import { host } from '@hermes/plugin-sdk' +import type { PluginContext } from '@hermes/plugin-sdk' + +import { $botMeta, $lastRoster, cachedUnionRoster } from './data' +import { runDesktopRoomCommandCycle } from './desktop-room-command-client' +import type { DesktopRoomCommand, DesktopRoomDescriptor } from './desktop-room-command-client' +import { + $groupChats, + boundedDesktopCommandSettled, + groupChatHostedGateway, + mintGroupRoomId, + scheduleGroupChatServerSync, + updateGroupChat +} from './group-chat' +import { groupChatBotsFromDescriptors, groupChatMemberBots } from './group-membership' +import { cancelGroupThreadForLeaseLoss, sendToGroupChat, stopGroupThread } from './group-rounds' +import type { GroupChat, GroupMember, ProfileRoute } from './types' + +const DESKTOP_ROOM_COMMAND_CONSUMER_KEY = 'desktop-room-command-consumer-v1' +const DESKTOP_ROOM_COMMAND_INTERVAL_MS = 60_000 +const DESKTOP_ROOM_COMMAND_PUSH_DEBOUNCE_MS = 250 + +let desktopRoomStorage: null | PluginContext['storage'] = null +let desktopRoomCommandConsumerId = '' +let desktopRoomCommandConsumerPromise: null | Promise = null +let desktopRoomCommandTimer: null | ReturnType = null +let desktopRoomCommandPushTimer: null | ReturnType = null +let desktopRoomCommandPushUnsub: null | (() => void) = null +let desktopRoomCommandRunning = false +let desktopRoomStopRunning = false +let desktopRoomCommandDisposed = true +let desktopRoomCommandRerun = false +let desktopRoomStopRerun = false + +const desktopRoomCommandPendingConnections = new Set() +const desktopRoomStopPendingConnections = new Set() +const desktopRoomCommandRetentions = new Map void>() + +const activeDesktopRoomCommands = new Map< + string, + { commandId: string; controller: AbortController; threadId: null | string } +>() + +function mintConsumerId() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { + return `desktop:${globalThis.crypto.randomUUID()}` + } + + return `desktop:${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + +function mintAuthorityToken() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { + return `authority:${globalThis.crypto.randomUUID()}` + } + + throw new Error('Secure Group Chat control is unavailable in this Desktop build.') +} + +async function authorityHash(token: string) { + if (!token || !globalThis.crypto?.subtle || typeof TextEncoder === 'undefined') { + return null + } + + const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)) + + return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, '0')).join('') +} + +async function ensureDesktopRoomCommandConsumerId() { + if (desktopRoomCommandConsumerId) { + return desktopRoomCommandConsumerId + } + + if (desktopRoomCommandConsumerPromise) { + return desktopRoomCommandConsumerPromise + } + + desktopRoomCommandConsumerPromise = (async () => { + if (!desktopRoomStorage?.get || !desktopRoomStorage?.set) { + throw new Error('Desktop storage is unavailable, so Group Chat control cannot be secured.') + } + + let stored = '' + + try { + stored = String((await desktopRoomStorage.get(DESKTOP_ROOM_COMMAND_CONSUMER_KEY, null)) || '').trim() + } catch (error) { + throw new Error('Desktop could not read secure Group Chat control identity.', { cause: error }) + } + + if (stored) { + desktopRoomCommandConsumerId = stored + + return stored + } + + const candidate = mintConsumerId() + + await desktopRoomStorage.set(DESKTOP_ROOM_COMMAND_CONSUMER_KEY, candidate) + const persisted = String(await desktopRoomStorage.get(DESKTOP_ROOM_COMMAND_CONSUMER_KEY, null)).trim() + if (persisted !== candidate) { + throw new Error('Desktop could not persist secure Group Chat control identity.') + } + desktopRoomCommandConsumerId = candidate + + return candidate + })().finally(() => { + desktopRoomCommandConsumerPromise = null + }) + + return desktopRoomCommandConsumerPromise +} + +export async function prepareDesktopRoomAuthority() { + const desktopCoordinatorId = await ensureDesktopRoomCommandConsumerId() + const desktopAuthorityToken = mintAuthorityToken() + const desktopAuthorityHash = await authorityHash(desktopAuthorityToken) + + if (!desktopAuthorityHash) { + throw new Error('Secure Group Chat control is unavailable in this Desktop build.') + } + + return { + desktopAuthorityHash, + desktopAuthorityToken, + desktopCoordinatorId + } +} + +function hasDesktopRoomExecutionEvidence(room: GroupChat) { + return Object.keys(room?.sessions || {}).length > 0 || Object.keys(room?.sessionOwners || {}).length > 0 +} + +async function adoptExistingDesktopRooms() { + const rooms = $groupChats.get() + + if ( + !Object.values(rooms).some( + room => + !groupChatHostedGateway(room) && + !room.tombstone && + (Boolean(room.desktopCoordinatorId) || hasDesktopRoomExecutionEvidence(room)) + ) + ) { + return + } + + const consumerId = await ensureDesktopRoomCommandConsumerId() + let changed = false + + for (const [name, room] of Object.entries(rooms)) { + if (groupChatHostedGateway(room) || room.tombstone) { + continue + } + + let coordinatorId = String(room.desktopCoordinatorId || '') + + if (!coordinatorId && hasDesktopRoomExecutionEvidence(room)) { + coordinatorId = consumerId + } + + if (coordinatorId !== consumerId) { + continue + } + + const token = String(room.desktopAuthorityToken || '') || mintAuthorityToken() + const hash = await authorityHash(token) + const roomId = String(room.roomId || '') || mintGroupRoomId() + + if (!hash) { + continue + } + + if ( + room.desktopCoordinatorId !== coordinatorId || + room.desktopAuthorityToken !== token || + room.desktopAuthorityHash !== hash || + room.roomId !== roomId + ) { + changed = true + updateGroupChat( + name, + current => ({ + ...current, + desktopAuthorityHash: hash, + desktopAuthorityToken: token, + desktopCoordinatorId: coordinatorId, + roomId + }), + { + sync: false + } + ) + } + } + + if (changed) { + scheduleGroupChatServerSync($groupChats.get()) + } +} + +function desktopRoomEntry(roomId: string, descriptors: DesktopRoomDescriptor[]) { + const descriptor = descriptors.find(room => room.roomId === roomId) + + if (!descriptor) { + return null + } + + const room = $groupChats.get()[descriptor.name] + + return room && !groupChatHostedGateway(room) ? ([descriptor.name, room] as const) : null +} + +function desktopCommandEligibleRooms(_connectionIds: string[]) { + const coordinator = String(desktopRoomCommandConsumerId || '') + + return Object.fromEntries( + Object.entries($groupChats.get()).filter(([, room]) => { + if (groupChatHostedGateway(room) || room?.tombstone) { + return false + } + + if (!coordinator || String(room?.desktopCoordinatorId || '') !== coordinator) { + return false + } + + if (!room.desktopAuthorityToken || !room.desktopAuthorityHash) { + return false + } + + return Array.isArray(room?.members) && room.members.length > 0 + }) + ) +} + +function retryableDesktopRoomCommand(message: string) { + return Object.assign(new Error(message), { + retryable: true + }) +} + +async function waitForDesktopRoomCommandSettlement(group: string, commandId: string, signal: AbortSignal | null) { + while (!desktopRoomCommandDisposed) { + if (signal?.aborted) { + throw retryableDesktopRoomCommand('The command moved to another Desktop.') + } + + const room = $groupChats.get()[group] + + if (!room) { + throw new Error('This Group Chat is no longer available.') + } + + if (room.desktopCommandSettled?.[commandId]) { + return true + } + + if (!room.running) { + return false + } + + await new Promise(resolve => setTimeout(resolve, 250)) + } + + throw retryableDesktopRoomCommand('Desktop closed before the Group Chat settled.') +} + +interface CommandExecutionContext { + consumerId: string + request: (method: string, params: Record) => Promise + route: ProfileRoute + signal: AbortSignal | null +} + +export async function executeDesktopRoomCommand( + command: DesktopRoomCommand, + descriptors: DesktopRoomDescriptor[], + { signal, request, consumerId, route }: CommandExecutionContext +) { + const assertLiveLease = () => { + if (signal?.aborted) { + throw retryableDesktopRoomCommand('The command moved to another Desktop.') + } + } + + assertLiveLease() + + const roomId = String(command.room_id || '') + const entry = desktopRoomEntry(roomId, descriptors) + + if (!entry) { + throw new Error('This Group Chat is no longer available on this Desktop.') + } + + const [group, room] = entry + const cached = cachedUnionRoster() + const roster = Array.isArray(cached?.profiles) ? cached.profiles : $lastRoster.get() + let members = groupChatMemberBots(group, roster, $botMeta.get()) + + if (command.action === 'send') { + const payload = command.payload || {} + + const frozenRecipients = + Array.isArray(payload.recipients) && payload.recipients.length + ? (payload.recipients as GroupMember[]) + : room.members || [] + + members = groupChatBotsFromDescriptors(frozenRecipients, roster) + + if (!members.length) { + throw retryableDesktopRoomCommand('Waiting for a Bot in this Group Chat to reconnect.') + } + + assertLiveLease() + + const message = String(payload.message || '').trim() + + if (!message) { + throw new Error('The Group Chat message is empty.') + } + + const commandId = String(command.command_id || '') + const localAbort = new AbortController() + const onLeaseAbort = () => localAbort.abort('lease-lost') + + signal?.addEventListener('abort', onLeaseAbort, { + once: true + }) + + if (signal?.aborted) { + localAbort.abort('lease-lost') + } + + activeDesktopRoomCommands.set(roomId, { + commandId, + controller: localAbort, + threadId: null + }) + + try { + while (!desktopRoomCommandDisposed) { + if (localAbort.signal.aborted) { + throw retryableDesktopRoomCommand('The command moved to another Desktop.') + } + + const thread = await Promise.resolve( + sendToGroupChat(group, members, message, null, undefined, { + entryId: commandId, + userName: String(payload.actor_display_name || 'Messaging') + }) + ) + + if (!thread) { + throw new Error('The Group Chat could not accept this message.') + } + + const active = activeDesktopRoomCommands.get(roomId) + + if (active?.commandId === commandId) { + active.threadId = thread + } + + if (await waitForDesktopRoomCommandSettlement(group, commandId, localAbort.signal)) { + return { + room_name: group, + thread_id: thread + } + } + } + } catch (error) { + if (localAbort.signal.aborted) { + if (localAbort.signal.reason === 'room-stop') { + return { + room_name: group, + stopped: true + } + } + + await cancelGroupThreadForLeaseLoss(group, members) + throw retryableDesktopRoomCommand('The command moved to another Desktop.') + } + + throw error + } finally { + signal?.removeEventListener('abort', onLeaseAbort) + + if (activeDesktopRoomCommands.get(roomId)?.controller === localAbort) { + activeDesktopRoomCommands.delete(roomId) + } + } + + throw retryableDesktopRoomCommand('Desktop closed before the Group Chat settled.') + } + + if (command.action === 'stop') { + const commandId = String(command.command_id || '') + const payload = command.payload || {} + const targetCommandId = String(payload.target_command_id || '') + const targetThreadId = String(payload.target_thread_id || '') + + if (room.desktopCommandSettled?.[commandId]) { + return { + room_name: group, + stopped: true + } + } + + let active = activeDesktopRoomCommands.get(roomId) + + if (targetCommandId) { + if ( + command.target_command_state === 'failed' && + command.target_result_code === 'superseded_by_stop' && + active?.commandId !== targetCommandId + ) { + return { + room_name: group, + stale: true, + stopped: true + } + } + + for (let attempt = 0; attempt < 40; attempt += 1) { + if ($groupChats.get()[group]?.desktopCommandSettled?.[targetCommandId]) { + return { + room_name: group, + stale: true, + stopped: false + } + } + + active = activeDesktopRoomCommands.get(roomId) + + if (active?.commandId === targetCommandId) { + break + } + + if (active && active.commandId !== targetCommandId) { + return { + room_name: group, + stale: true, + stopped: false + } + } + + if (signal?.aborted) { + throw retryableDesktopRoomCommand('The command moved to another Desktop.') + } + + await new Promise(resolve => setTimeout(resolve, 50)) + } + + if (active?.commandId !== targetCommandId) { + throw retryableDesktopRoomCommand('Waiting for the earlier Group Chat message to start.') + } + } + + const latestThread = [...room.log].reverse().find(item => item?.thread)?.thread || null + const stopThread = targetCommandId ? active?.threadId || targetThreadId || latestThread : targetThreadId + + if (!targetCommandId && targetThreadId && latestThread && targetThreadId !== latestThread) { + return { + room_name: group, + stale: true, + stopped: false + } + } + + if (!stopThread) { + return { + room_name: group, + stale: true, + stopped: false + } + } + + if (targetCommandId) { + active?.controller.abort('room-stop') + } + await stopGroupThread(group, stopThread, members) + updateGroupChat(group, current => ({ + ...current, + desktopCommandSettled: boundedDesktopCommandSettled({ + ...(current.desktopCommandSettled || {}), + ...(targetCommandId && active?.commandId + ? { + [active.commandId]: Date.now() + } + : {}), + [commandId]: Date.now() + }) + })) + + return { + room_name: group, + stopped: true + } + } + + throw new Error('Unsupported Group Chat command.') +} + +async function desktopRoomCommandConnections() { + const byConnection = new Map() + + if (typeof host.profileRoutes === 'function') { + try { + const routes = await host.profileRoutes() + + for (const route of Array.isArray(routes) ? routes : []) { + const profile = String(route?.targetProfile || route?.profile || '') + const connectionId = String(route?.connectionId || '') + + if (profile === 'default' && !byConnection.has(connectionId)) { + byConnection.set(connectionId, route as ProfileRoute) + } + } + } catch { + /* the active route below remains a compatibility fallback */ + } + } + + const active = String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || '') + + if (!byConnection.has(active)) { + byConnection.set(active, { + connectionId: active, + mode: 'remote', + profile: 'default', + targetProfile: 'default' + }) + } + + return [...byConnection.entries()].map(([id, route]) => ({ + id, + route + })) +} + +function syncDesktopRoomCommandRetention(connections: Array<{ id: string; route: ProfileRoute }>) { + if (typeof host.retainProfileSocket !== 'function') { + return + } + + const live = new Set(connections.map(connection => connection.id)) + + for (const [id, release] of [...desktopRoomCommandRetentions]) { + if (!live.has(id)) { + desktopRoomCommandRetentions.delete(id) + + try { + release() + } catch { + /* teardown stays best-effort */ + } + } + } + + if (desktopRoomCommandDisposed) { + return + } + + for (const connection of connections) { + if (!desktopRoomCommandRetentions.has(connection.id)) { + desktopRoomCommandRetentions.set(connection.id, host.retainProfileSocket(connection.route)) + } + } +} + +function releaseDesktopRoomCommandRetention() { + for (const release of desktopRoomCommandRetentions.values()) { + try { + release() + } catch { + /* teardown stays best-effort */ + } + } + + desktopRoomCommandRetentions.clear() +} + +function scheduleDesktopRoomCommandPump(connectionId: null | string = null) { + if (desktopRoomCommandDisposed || typeof setTimeout !== 'function') { + return + } + + const key = connectionId === null ? '*' : String(connectionId) + + desktopRoomCommandPendingConnections.add(key) + desktopRoomStopPendingConnections.add(key) + + if (desktopRoomCommandPushTimer !== null) { + return + } + + desktopRoomCommandPushTimer = setTimeout(() => { + desktopRoomCommandPushTimer = null + + const pending = new Set(desktopRoomCommandPendingConnections) + const stopPending = new Set(desktopRoomStopPendingConnections) + + desktopRoomCommandPendingConnections.clear() + desktopRoomStopPendingConnections.clear() + void runDesktopRoomCommandPump(pending.has('*') ? null : pending) + void runDesktopRoomStopPump(stopPending.has('*') ? null : stopPending) + }, DESKTOP_ROOM_COMMAND_PUSH_DEBOUNCE_MS) +} + +async function runDesktopRoomCommandPump(targetConnectionIds: null | Set = null) { + if (desktopRoomCommandDisposed) { + return + } + + if (desktopRoomCommandRunning) { + desktopRoomCommandRerun = true + + if (targetConnectionIds === null) { + desktopRoomCommandPendingConnections.add('*') + } else { + targetConnectionIds.forEach(id => desktopRoomCommandPendingConnections.add(String(id))) + } + + return + } + + desktopRoomCommandRunning = true + + try { + await ensureDesktopRoomCommandConsumerId() + + const connections = await desktopRoomCommandConnections() + const rooms = desktopCommandEligibleRooms(connections.map(connection => connection.id)) + + if (!Object.keys(rooms).length) { + syncDesktopRoomCommandRetention([]) + + return + } + + syncDesktopRoomCommandRetention(connections) + + const selected = + targetConnectionIds === null + ? connections + : connections.filter(connection => targetConnectionIds.has(connection.id)) + + await runDesktopRoomCommandCycle({ + routes: selected.map(connection => connection.route), + consumerId: desktopRoomCommandConsumerId, + rooms, + request: (route, method, params) => host.requestProfile(route, method, params), + execute: executeDesktopRoomCommand, + actions: ['send'], + shouldContinue: () => !desktopRoomCommandDisposed + }) + } catch { + // A reconnect or older backend leaves durable commands pending. + } finally { + desktopRoomCommandRunning = false + + if (desktopRoomCommandRerun && !desktopRoomCommandDisposed) { + desktopRoomCommandRerun = false + + const pending = [...desktopRoomCommandPendingConnections] + + desktopRoomCommandPendingConnections.clear() + + if (!pending.length || pending.includes('*')) { + scheduleDesktopRoomCommandPump() + } else { + pending.forEach(connectionId => scheduleDesktopRoomCommandPump(connectionId)) + } + } + } +} + +async function runDesktopRoomStopPump(targetConnectionIds: null | Set = null) { + if (desktopRoomCommandDisposed) { + return + } + + if (desktopRoomStopRunning) { + desktopRoomStopRerun = true + + if (targetConnectionIds === null) { + desktopRoomStopPendingConnections.add('*') + } else { + targetConnectionIds.forEach(id => desktopRoomStopPendingConnections.add(String(id))) + } + + return + } + + desktopRoomStopRunning = true + + try { + await ensureDesktopRoomCommandConsumerId() + + const connections = await desktopRoomCommandConnections() + const rooms = desktopCommandEligibleRooms(connections.map(connection => connection.id)) + + const selected = + targetConnectionIds === null + ? connections + : connections.filter(connection => targetConnectionIds.has(connection.id)) + + if (!Object.keys(rooms).length) { + return + } + + await runDesktopRoomCommandCycle({ + routes: selected.map(connection => connection.route), + consumerId: desktopRoomCommandConsumerId, + rooms, + request: (route, method, params) => host.requestProfile(route, method, params), + execute: executeDesktopRoomCommand, + actions: ['stop'], + shouldContinue: () => !desktopRoomCommandDisposed + }) + } catch { + // A reconnect or older backend leaves durable Stops pending. + } finally { + desktopRoomStopRunning = false + + if (desktopRoomStopRerun && !desktopRoomCommandDisposed) { + desktopRoomStopRerun = false + + const pending = [...desktopRoomStopPendingConnections] + + desktopRoomStopPendingConnections.clear() + void runDesktopRoomStopPump(!pending.length || pending.includes('*') ? null : new Set(pending)) + } + } +} + +export async function startDesktopRoomCommandRuntime(storage: PluginContext['storage']) { + desktopRoomStorage = storage + desktopRoomCommandDisposed = false + + await adoptExistingDesktopRooms() + + if (desktopRoomCommandDisposed || desktopRoomStorage !== storage) { + return + } + + if (typeof setInterval !== 'function' || desktopRoomCommandTimer !== null) { + return + } + + void runDesktopRoomCommandPump() + void runDesktopRoomStopPump() + desktopRoomCommandTimer = setInterval(() => { + void runDesktopRoomCommandPump() + void runDesktopRoomStopPump() + }, DESKTOP_ROOM_COMMAND_INTERVAL_MS) + + if (desktopRoomCommandPushUnsub === null && typeof host.onEvent === 'function') { + desktopRoomCommandPushUnsub = host.onEvent('desktop_rooms.commands.pending', event => + scheduleDesktopRoomCommandPump(event?.connectionId ?? null) + ) + } +} + +export function stopDesktopRoomCommandRuntime() { + desktopRoomCommandDisposed = true + for (const active of activeDesktopRoomCommands.values()) { + active.controller.abort('runtime-stopped') + } + activeDesktopRoomCommands.clear() + desktopRoomCommandRerun = false + desktopRoomStopRerun = false + desktopRoomCommandPendingConnections.clear() + desktopRoomStopPendingConnections.clear() + releaseDesktopRoomCommandRetention() + + if (desktopRoomCommandTimer !== null) { + clearInterval(desktopRoomCommandTimer) + desktopRoomCommandTimer = null + } + + if (desktopRoomCommandPushTimer !== null) { + clearTimeout(desktopRoomCommandPushTimer) + desktopRoomCommandPushTimer = null + } + + if (desktopRoomCommandPushUnsub !== null) { + try { + desktopRoomCommandPushUnsub() + } catch { + /* older host disposer */ + } + + desktopRoomCommandPushUnsub = null + } + + desktopRoomStorage = null +} diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts index 2e1ae75c40181..2b8b6d9700e5b 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.test.ts @@ -480,6 +480,26 @@ describe('gateway mirror', () => { expect(Object.keys(snapshot.rooms)).toEqual([]) }) + it('publishes a newly created silent room so messaging clients can discover it', async () => { + const { chat } = await loadRoom() + + const snapshot = chat.groupChatSyncSnapshot({ + Planning: { + desktopAuthorityHash: 'a'.repeat(64), + log: [], + members: [{ name: 'research' }, { name: 'builder' }], + roomId: 'room-planning', + watermarks: {} + } + }) + + expect(snapshot.rooms['id:room-planning']).toMatchObject({ + desktopAuthorityHash: 'a'.repeat(64), + name: 'Planning', + roomId: 'room-planning' + }) + }) + it('an empty hydrate cannot erase a shared room mirror', async () => { const room = await loadRoom() const before = room.gateway.rpc.length diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.ts index f430e83a98f7b..5d8cd8aeecdf8 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.ts @@ -57,6 +57,7 @@ let groupChatSyncTimer: ReturnType | null = null * identity fields, without any of `GroupChat`'s runtime/orchestration state. */ interface GroupChatSyncRoom { continuityMode?: 'desktop' | 'distributed' | 'gateway' + desktopAuthorityHash?: null | string hosted?: null | string hostedEpoch?: null | number image?: null | string @@ -278,7 +279,14 @@ export function groupChatSyncSnapshot( const ranked = Object.entries(all || {}) // Empty runtime tombstones are used to stop an in-flight room after // disband. They are not real rooms and must never reappear on mobile. - .filter(([, room]) => room && Array.isArray(room.log) && room.log.length > 0) + .filter( + ([, room]) => + room && + !room.tombstone && + Array.isArray(room.log) && + (room.log.length > 0 || + (typeof room.roomId === 'string' && room.roomId && Array.isArray(room.members) && room.members.length >= 2)) + ) .sort(([, left], [, right]) => { const leftAt = Number(left.log[left.log.length - 1]?.at || 0) const rightAt = Number(right.log[right.log.length - 1]?.at || 0) @@ -327,7 +335,7 @@ export function groupChatSyncSnapshot( ? { thread: String(entry.thread).slice(0, 128) } - : {}) + : {}), })) const compact: GroupChatSyncRoom = { @@ -342,6 +350,11 @@ export function groupChatSyncSnapshot( // authority fence instead of interpreting omission as a local takeover. hosted: groupChatHostedGateway(room) || null, hostedEpoch: groupChatHostedEpoch(room) || null, + ...(typeof room.desktopAuthorityHash === 'string' && /^[a-f0-9]{64}$/.test(room.desktopAuthorityHash) + ? { + desktopAuthorityHash: room.desktopAuthorityHash + } + : {}), continuityMode: groupChatContinuityMode(room), log, revision: Math.max(0, Number(room?.syncRevision ?? room?.revision ?? 0)), @@ -914,6 +927,13 @@ export function mergeRemoteGroupChatSnapshotIntoRooms( : remoteRevision >= localRevision && Object.prototype.hasOwnProperty.call(projected, 'image') ? projected.image || null : existing.image || null, + desktopAuthorityHash: + (typeof existing.desktopAuthorityHash === 'string' && /^[a-f0-9]{64}$/.test(existing.desktopAuthorityHash) + ? existing.desktopAuthorityHash + : null) || + (typeof projected.desktopAuthorityHash === 'string' && /^[a-f0-9]{64}$/.test(projected.desktopAuthorityHash) + ? projected.desktopAuthorityHash + : null), hosted: cachedHosted || null, hostedEpoch: cachedHostedEpoch || null, continuityMode: cachedHosted @@ -961,6 +981,16 @@ export function mergeRemoteGroupChatSnapshotIntoRooms( return rooms } +export function boundedDesktopCommandSettled(value: unknown, limit = 128) { + return Object.fromEntries( + Object.entries(value && typeof value === 'object' ? value : {}) + .map(([id, at]) => [String(id), Math.max(0, Number(at || 0))] as const) + .filter(([id]) => id) + .sort(([, left], [, right]) => right - left) + .slice(0, limit) + ) +} + export function durableGroupChatRooms(all: Record = $groupChats.get()) { const durable: Record = {} @@ -989,6 +1019,11 @@ export function durableGroupChatRooms(all: Record = $groupCha // name-keyed identity — same field updateGroupChat's inline map // already carries. roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + desktopCoordinatorId: + typeof room.desktopCoordinatorId === 'string' && room.desktopCoordinatorId ? room.desktopCoordinatorId : null, + desktopAuthorityToken: + typeof room.desktopAuthorityToken === 'string' && room.desktopAuthorityToken ? room.desktopAuthorityToken : null, + desktopCommandSettled: boundedDesktopCommandSettled(room.desktopCommandSettled), hosted: groupChatHostedGateway(room) || null, hostedEpoch: groupChatHostedEpoch(room) || null, hostedConnectionId: @@ -1593,6 +1628,11 @@ export function updateGroupChat( members: Array.isArray(room.members) ? room.members : [], // Immutable room identity: the member-session title for new rooms. roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + desktopCoordinatorId: + typeof room.desktopCoordinatorId === 'string' && room.desktopCoordinatorId ? room.desktopCoordinatorId : null, + desktopAuthorityToken: + typeof room.desktopAuthorityToken === 'string' && room.desktopAuthorityToken ? room.desktopAuthorityToken : null, + desktopCommandSettled: boundedDesktopCommandSettled(room.desktopCommandSettled), hosted: groupChatHostedGateway(room) || null, hostedEpoch: groupChatHostedEpoch(room) || null, hostedConnectionId: @@ -1674,14 +1714,20 @@ export function appendGroupChatEntry( from: GroupMessageAuthor, text: string, thread?: null | string, - images?: Attachment[] + images?: Attachment[], + { entryId = '', external = false }: { entryId?: string; external?: boolean } = {} ): GroupMessage { const entry: GroupMessage = { - id: groupChatEntryId(), + id: entryId || groupChatEntryId(), at: Date.now(), from, text: normalizeGroupChatText(text), - thread: thread || 'legacy' + thread: thread || 'legacy', + ...(external + ? { + external: true + } + : {}) } if (Array.isArray(images) && images.length) { diff --git a/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx b/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx index 23c64e07fdc9a..9f20e4b0fc977 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/group-continuity-creation.test.tsx @@ -12,6 +12,11 @@ const mocks = vi.hoisted(() => ({ createAutonomousHostedGroupChat: vi.fn(), markHostedRoomLocallyDeleted: vi.fn(), notify: vi.fn(), + prepareDesktopRoomAuthority: vi.fn(async () => ({ + desktopAuthorityHash: 'a'.repeat(64), + desktopAuthorityToken: 'authority:test', + desktopCoordinatorId: 'desktop:test' + })), probeHostedRoomMembers: vi.fn(), saveBotMeta: vi.fn(async (_owner: unknown, _patch: unknown) => undefined) })) @@ -45,6 +50,10 @@ vi.mock('./hosted-room-runtime', () => ({ probeHostedRoomMembers: mocks.probeHostedRoomMembers })) +vi.mock('./desktop-room-command-runtime', () => ({ + prepareDesktopRoomAuthority: mocks.prepareDesktopRoomAuthority +})) + const roster: RosterRow[] = [ { connectionId: 'host-a', @@ -78,6 +87,7 @@ const eligibleProbe: HostedRoomProbe = { }, persistentProcess: true, routeGrantFingerprint: false, + reciprocalControl: false, reason: null, roomLink: null }, @@ -179,6 +189,8 @@ describe('automatic Group Chat continuity', () => { await waitFor(() => expect(mocks.createAutonomousHostedGroupChat).toHaveBeenCalledTimes(1)) const { $groupChats } = await import('./group-chat') + + await waitFor(() => expect(Object.values($groupChats.get())).toHaveLength(1)) const created = Object.values($groupChats.get())[0] expect(created).toMatchObject({ @@ -326,6 +338,8 @@ describe('automatic Group Chat continuity', () => { }) const { $groupChats } = await import('./group-chat') + + await waitFor(() => expect(Object.values($groupChats.get())).toHaveLength(1)) const created = Object.values($groupChats.get())[0] expect(created.continuityMode).toBe('desktop') diff --git a/apps/desktop/src/plugins/hermes-bots/group-membership.ts b/apps/desktop/src/plugins/hermes-bots/group-membership.ts index 8d8e8a00e4935..afe11dc96a373 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-membership.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-membership.ts @@ -248,7 +248,7 @@ export function groupChatMemberBots( * by friendly name against rows on the same connection. Unresolvable * descriptors return as-is — they stay visible-but-degraded ghosts and must * never be used as a `profile:` target. */ -function resolveLegacyMemberDescriptor(descriptor: RosterRow, roster: RosterRow[]): RosterRow { +export function resolveLegacyMemberDescriptor(descriptor: RosterRow, roster: RosterRow[]): RosterRow { const rows = roster || [] if (rows.some(bot => botRosterKey(bot) === botRosterKey(descriptor))) { @@ -296,6 +296,25 @@ function resolveLegacyMemberDescriptor(descriptor: RosterRow, roster: RosterRow[ return match || descriptor } +export function groupChatBotsFromDescriptors(descriptors: GroupMember[], roster: RosterRow[]): RosterRow[] { + const members: RosterRow[] = [] + const seen = new Set() + + for (const descriptor of Array.isArray(descriptors) ? descriptors : []) { + const resolved = resolveLegacyMemberDescriptor(descriptor, roster) + const key = botRosterKey(resolved) + + if (!key || seen.has(key)) { + continue + } + + seen.add(key) + members.push((roster || []).find(bot => !bot?.ghost && botRosterKey(bot) === key) || resolved) + } + + return members +} + /** Persist source-qualified identities for every selected member. The active * source's row may become remote after a connection switch, so retaining it * here is what keeps the same room intact across machines. */ diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index a1f8d1f8a6593..91cdbb554d552 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -40,6 +40,11 @@ function hostedConnectionName(room: null | Partial | undefined) { return room?.members?.find(member => member.connectionLabel)?.connectionLabel || botsText().group.thisHost } +interface SendGroupChatOptions { + entryId?: string + userName?: string +} + // ── group chats: bounded round-robin coordination over a shared room log ───── // // Behavioral model (clean-room): a group conversation is ONE ordered room log @@ -581,6 +586,38 @@ export async function stopGroupThread(group: string, thread: null | string, memb } } +/** Fence a classic-room turn after this Desktop loses its gateway command + * lease. Unlike a user Stop, this adds no durable holds, so the same command + * can be leased to the current owner and resumed idempotently. */ +export async function cancelGroupThreadForLeaseLoss( + group: string, + members: GroupMember[] | null = null +) { + const room = $groupChats.get()[group] || {} + const roster = Array.isArray(members) && members.length ? members : room.members || [] + const turnName = room.turn || null + + updateGroupChat(group, current => ({ + ...current, + epoch: (current.epoch || 0) + 1, + running: false, + turn: null + })) + + const onTurn = turnName ? roster.find(member => member?.name === turnName) : null + const sessionId = onTurn ? (room.sessions || {})[groupMemberKey(onTurn)] : null + + if (onTurn && sessionId) { + try { + await requestForBot(onTurn, 'session.interrupt', { + session_id: sessionId + }) + } catch { + /* the epoch fence still prevents stale room commits */ + } + } +} + /** Drive one bounded round-robin turn for ONE THREAD. Serial — one member at * a time. A newer user send bumps the room epoch; this loop notices at the * next member boundary, bails, and the newest send's own loop takes over. @@ -981,6 +1018,13 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], // the round cap ended the drive, not consensus. (#94478) exitKind = 'capped' } finally { + const externalIds = (Array.isArray(($groupChats.get()[group] || {}).log) + ? $groupChats.get()[group].log + : [] + ) + .filter(entry => entry?.external && groupThreadOf(entry) === thread && entry?.id) + .map(entry => String(entry.id)) + if (isCurrent()) { recordGroupActivity(group, { kind: exitKind, @@ -990,6 +1034,14 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], updateGroupChat(group, (r: GroupChatRoom) => { r.running = false r.turn = null + r.desktopCommandSettled = Object.fromEntries( + Object.entries({ + ...(r.desktopCommandSettled || {}), + ...Object.fromEntries(externalIds.map(id => [id, Date.now()])) + }) + .sort(([, left], [, right]) => Number(right) - Number(left)) + .slice(0, 128) + ) return r }) @@ -1058,13 +1110,17 @@ export function sendToGroupChat( members: GroupMember[], text: string, thread?: null | string, - images?: Attachment[] + images?: Attachment[], + options: SendGroupChatOptions = {} ): null | string { const trimmed = String(text || '').trim() const attached = Array.isArray(images) ? images.filter((img: Attachment) => img && img.data) : [] const roomBeforeSend = $groupChats.get()[group] const hosted = groupChatHostedGateway(roomBeforeSend) const connectionName = hostedConnectionName(roomBeforeSend) + const externalId = String(options.entryId || '').trim() + const userName = String(options.userName || 'You').trim().slice(0, 128) || 'You' + if ((!trimmed && !attached.length) || !members.length) { return null } @@ -1096,6 +1152,39 @@ export function sendToGroupChat( } const target = thread || mintGroupThreadId() + + if (externalId) { + const existing = (Array.isArray(roomBeforeSend?.log) ? roomBeforeSend.log : []).find(entry => entry?.id === externalId) + + if (existing) { + const existingThread = existing.thread || 'legacy' + + if (roomBeforeSend?.desktopCommandSettled?.[externalId] || roomBeforeSend?.running) { + return existingThread + } + + updateGroupChat(group, current => ({ + ...current, + members: durableGroupChatMembers(members), + epoch: (current.epoch || 0) + 1, + running: true + })) + recordGroupActivity(group, { + kind: 'queued', + member: userName, + thread: existingThread + }) + void runGroupChatRounds(group, members, existingThread).catch(() => { + updateGroupChat(group, current => ({ + ...current, + running: false + })) + }) + + return existingThread + } + } + $groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false @@ -1113,11 +1202,15 @@ export function sendToGroupChat( group, { kind: 'user', - name: 'You' + name: userName }, trimmed, target, - attached + attached, + { + entryId: externalId, + external: Boolean(externalId) + } ) if (!sent) { diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts index 1b9f36cd2264d..c9a90fe53af36 100644 --- a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.test.ts @@ -58,7 +58,7 @@ describe('hosted Group Chat capability negotiation', () => { driver: true, persistent_process: true, authority_gateway_id: 'install:home', - features: ['peer_route_grant_fingerprint'], + features: ['peer_route_grant_fingerprint', 'reciprocal_room_control'], methods: ['groups.peer.revoke_exact'], max_log_limit: 250 }, @@ -79,6 +79,7 @@ describe('hosted Group Chat capability negotiation', () => { exactPeerGrantRevoke: true, persistentProcess: true, routeGrantFingerprint: true, + reciprocalControl: true, maxLogLimit: 250 }) expect(isHostedRoomContinuityEligible(capable)).toBe(true) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts index 9a3bdbbd82af1..d73b5eba9dde5 100644 --- a/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-client.ts @@ -42,6 +42,7 @@ export interface HostedRoomCapability { maxLogLimit?: number persistentProcess: boolean | null routeGrantFingerprint: boolean + reciprocalControl: boolean reason: null | string roomLink: null | RoomLinkCapability } @@ -311,6 +312,7 @@ export function classifyHostedRoomCapability( authorityId: null, persistentProcess: null, routeGrantFingerprint: false, + reciprocalControl: false, roomLink: null, limits: HOSTED_ROOM_CLIENT_LIMITATIONS } @@ -327,11 +329,15 @@ export function classifyHostedRoomCapability( authorityId: null, persistentProcess: null, routeGrantFingerprint: false, + reciprocalControl: false, roomLink: null, limits: HOSTED_ROOM_CLIENT_LIMITATIONS } } + const features = Array.isArray(capabilities.features) ? capabilities.features.map(String) : [] + const reciprocalControl = features.includes('reciprocal_room_control') + if (capabilities.driver !== true) { return { kind: 'unsupported', @@ -341,6 +347,7 @@ export function classifyHostedRoomCapability( authorityId: null, persistentProcess: capabilities.persistent_process === true, routeGrantFingerprint: false, + reciprocalControl, roomLink: roomLinkCapability(capabilities.room_link), limits: HOSTED_ROOM_CLIENT_LIMITATIONS } @@ -357,6 +364,7 @@ export function classifyHostedRoomCapability( authorityId: null, persistentProcess: capabilities.persistent_process === true, routeGrantFingerprint: false, + reciprocalControl, roomLink: roomLinkCapability(capabilities.room_link), limits: HOSTED_ROOM_CLIENT_LIMITATIONS } @@ -372,6 +380,7 @@ export function classifyHostedRoomCapability( persistentProcess: capabilities.persistent_process === true, routeGrantFingerprint: Array.isArray(capabilities.features) && capabilities.features.includes('peer_route_grant_fingerprint'), + reciprocalControl, roomLink: roomLinkCapability(capabilities.room_link), maxLogLimit: positiveInteger(capabilities.max_log_limit, 100) || 100, limits: HOSTED_ROOM_CLIENT_LIMITATIONS diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts index d1e91cf701369..4cdd4372fae9b 100644 --- a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts @@ -517,8 +517,8 @@ describe('hosted Group Chat runtime', () => { it('reconciles peer members without rewriting them onto the home gateway', async () => { const routes = [ - { connectionId: 'gateway-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, - { connectionId: 'gateway-b', mode: 'remote' as const, profile: 'default', targetProfile: 'default' } + { connectionId: 'gateway-b', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'gateway-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' } ] const serverMembers = [ @@ -548,6 +548,7 @@ describe('hosted Group Chat runtime', () => { return { authority_gateway_id: `install:${connectionId}`, driver: true, + features: ['reciprocal_room_control'], persistent_process: true } } @@ -588,6 +589,22 @@ describe('hosted Group Chat runtime', () => { return { events: [], has_more: false, latest_seq: 0 } } + if (method === 'groups.control.invite') { + return { + authority_epoch: 1, + authority_gateway_id: 'install:gateway-a', + control_token: 'private-control-token', + expires_at: 253_402_300_799, + home_url: 'https://gateway-a.example.test:19445', + member_count: 2, + room_name: 'Distributed' + } + } + + if (method === 'groups.control.register') { + return { registered: true } + } + throw new Error(`unexpected method: ${method}`) }, routes) @@ -610,6 +627,16 @@ describe('hosted Group Chat runtime', () => { expect.objectContaining({ connectionId: 'gateway-a', name: 'research' }), expect.objectContaining({ connectionId: 'gateway-b', name: 'builder' }) ]) + await vi.waitFor(() => { + expect(loaded.calls.find(call => call.method === 'groups.control.register')).toMatchObject({ + connectionId: 'gateway-b', + params: { + member_id: 'member-2-builder', + profile: 'builder', + room_id: 'room-1' + } + }) + }) loaded.runtime.stopHostedRoomRuntime() }) @@ -1084,31 +1111,55 @@ describe('hosted Group Chat runtime', () => { } ) - it('creates a multi-host Group Chat with target-issued scoped grants', async () => { - const routes = [ - { connectionId: 'host-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, - { connectionId: 'host-b', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, - { connectionId: 'host-b', mode: 'remote' as const, profile: 'builder', targetProfile: 'builder' } - ] + it.each([true, false])( + 'creates a multi-host Group Chat with target-issued scoped grants (reciprocal control: %s)', + async reciprocalControl => { + const routes = [ + { connectionId: 'host-a', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'host-b', mode: 'remote' as const, profile: 'default', targetProfile: 'default' }, + { connectionId: 'host-b', mode: 'remote' as const, profile: 'builder', targetProfile: 'builder' } + ] - const loaded = await loadRuntime((method, _params, route) => { - const connectionId = String(route?.connectionId || '') + const loaded = await loadRuntime((method, _params, route) => { + const connectionId = String(route?.connectionId || '') - if (method === 'groups.capabilities') { - return { - authority_gateway_id: `install:${connectionId}`, - driver: true, - persistent_process: true, - room_link: { - enabled: true, - endpoint: { - available: true, - url: `https://${connectionId}.example.test:19445` - }, + if (method === 'groups.capabilities') { + return { + authority_gateway_id: `install:${connectionId}`, + driver: true, + persistent_process: true, + features: reciprocalControl ? ['reciprocal_room_control'] : [], + room_link: { + enabled: true, + endpoint: { + available: true, + url: `https://${connectionId}.example.test:19445` + }, + catalog: { + attachments: false, + catalog_digest: `digest:${connectionId}`, + installation_id: `install:${connectionId}`, + link_modes: ['direct'], + persistent_process: true, + protocol_versions: [2], + text: true + } + } + } + } + + if (method === 'groups.list') { + return { rooms: [] } + } + + if (method === 'groups.peer.invite') { + return { + grant: 'grant:builder', + target_profile: 'builder', catalog: { attachments: false, - catalog_digest: `digest:${connectionId}`, - installation_id: `install:${connectionId}`, + catalog_digest: 'digest:host-b', + installation_id: 'install:host-b', link_modes: ['direct'], persistent_process: true, protocol_versions: [2], @@ -1116,102 +1167,112 @@ describe('hosted Group Chat runtime', () => { } } } - } - - if (method === 'groups.list') { - return { rooms: [] } - } - if (method === 'groups.peer.invite') { - return { - grant: 'grant:builder', - target_profile: 'builder', - catalog: { - attachments: false, - catalog_digest: 'digest:host-b', - installation_id: 'install:host-b', - link_modes: ['direct'], - persistent_process: true, - protocol_versions: [2], - text: true + if (method === 'groups.create') { + return { + room: { + authority_epoch: 1, + authority_gateway_id: 'install:host-a', + room_id: 'room-multi' + } } } - } - if (method === 'groups.create') { - return { - room: { + if (method === 'groups.control.invite') { + return { authority_epoch: 1, authority_gateway_id: 'install:host-a', - room_id: 'room-multi' + control_token: 'private-control-token', + expires_at: 2_000_000_000, + home_url: 'https://host-a.example.test:19445', + member_count: 2, + room_name: 'Multi' } } - } - if (method === 'groups.peer.register') { - return { registered: true } - } + if (method === 'groups.control.register') { + return { registered: true } + } - throw new Error(`unexpected method: ${method}`) - }, routes) + if (method === 'groups.peer.register') { + return { registered: true } + } - const storage = scriptedStorage(loaded.storage).storage + throw new Error(`unexpected method: ${method}`) + }, routes) - await loaded.runtime.startHostedRoomRuntime(storage) + const storage = scriptedStorage(loaded.storage).storage - const members: GroupMember[] = [ - { - connectionId: 'host-a', - name: 'research', - route: routes[0], - sourceScoped: true, - targetProfile: 'research' - }, - { - connectionId: 'host-b', - name: 'builder', - route: routes[2], - sourceScoped: true, - targetProfile: 'builder' - } - ] + await loaded.runtime.startHostedRoomRuntime(storage) - const probe = await loaded.runtime.probeHostedRoomMembers(members) + const members: GroupMember[] = [ + { + connectionId: 'host-a', + name: 'research', + route: routes[0], + sourceScoped: true, + targetProfile: 'research' + }, + { + connectionId: 'host-b', + name: 'builder', + route: routes[2], + sourceScoped: true, + targetProfile: 'builder' + } + ] - expect(probe.route).toMatchObject({ - homeConnectionId: 'host-a', - kind: 'multi-gateway', - remoteConnectionIds: ['host-b'] - }) - await expect( - loaded.runtime.createAutonomousHostedGroupChat({ - members: [ - { handle: 'research', member: members[0], profile: 'research' }, - { handle: 'builder', member: members[1], profile: 'builder' } - ], - name: 'Multi', - probe, - roomId: 'room-multi' + const probe = await loaded.runtime.probeHostedRoomMembers(members) + + expect(probe.route).toMatchObject({ + homeConnectionId: 'host-a', + kind: 'multi-gateway', + remoteConnectionIds: ['host-b'] + }) + await expect( + loaded.runtime.createAutonomousHostedGroupChat({ + members: [ + { handle: 'research', member: members[0], profile: 'research' }, + { handle: 'builder', member: members[1], profile: 'builder' } + ], + name: 'Multi', + probe, + roomId: 'room-multi' + }) + ).resolves.toMatchObject({ + authorityId: 'install:host-a', + connectionId: 'host-a', + continuityMode: 'distributed' }) - ).resolves.toMatchObject({ - authorityId: 'install:host-a', - connectionId: 'host-a', - continuityMode: 'distributed' - }) - expect(loaded.calls.find(call => call.method === 'groups.peer.invite')?.connectionId).toBe('host-b') - expect(loaded.calls.find(call => call.method === 'groups.create')?.connectionId).toBe('host-a') - expect(loaded.calls.find(call => call.method === 'groups.peer.register')?.params).toMatchObject({ - grant: 'grant:builder', - member_id: 'member-2-builder', - room_id: 'room-multi', - target_profile: 'builder', - target_url: 'https://host-b.example.test:19445/p/builder' - }) - expect((loaded.storage.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + expect(loaded.calls.find(call => call.method === 'groups.peer.invite')?.connectionId).toBe('host-b') + expect(loaded.calls.find(call => call.method === 'groups.create')?.connectionId).toBe('host-a') + expect(loaded.calls.find(call => call.method === 'groups.peer.register')?.params).toMatchObject({ + grant: 'grant:builder', + member_id: 'member-2-builder', + room_id: 'room-multi', + target_profile: 'builder', + target_url: 'https://host-b.example.test:19445/p/builder' + }) - loaded.runtime.stopHostedRoomRuntime() - }) + if (reciprocalControl) { + expect(loaded.calls.find(call => call.method === 'groups.control.register')?.params).toMatchObject({ + authority_gateway_id: 'install:host-a', + member_count: 2, + member_id: 'member-2-builder', + profile: 'builder', + room_id: 'room-multi', + room_name: 'Multi' + }) + } else { + expect(loaded.calls.some(call => call.method.startsWith('groups.control.'))).toBe(false) + } + + expect((loaded.storage.get('hosted-room-cleanup-v1') as { operations: unknown[] }).operations).toEqual([]) + + loaded.runtime.stopHostedRoomRuntime() + } + ) it('durably disbands and revokes a partial multi-host setup', async () => { const routes = [ @@ -1680,6 +1741,12 @@ describe('hosted Group Chat runtime', () => { expect( loaded.runtime.hostedRoomDriverDisplayStatus({ kind: 'ready' }, { counts: { queued: 1 }, working: false }) ).toMatchObject({ kind: 'queued', canStop: true }) + expect( + loaded.runtime.hostedRoomDriverDisplayStatus( + { kind: 'needs-attention' }, + { counts: { stopping: 1 }, working: true } + ) + ).toMatchObject({ kind: 'stopping', canStop: false }) expect(loaded.runtime.hostedRoomPollFingerprint({ revision: 4, latest_seq: 9 })).toBe('4:9') }) }) diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts index 69d50b97ef071..917e9c1256f8a 100644 --- a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.ts @@ -70,6 +70,11 @@ const hostedRoomPollGenerations = new Map() const hostedRoomMutationGenerations = new Map() const hostedRoomLocallyDeleted = new Set() const hostedRoomInventoriedConnections = new Set() +const hostedRoomControlEnrolled = new Set() +const hostedRoomControlPending = new Map() +const hostedRoomControlRetryAfter = new Map() +let hostedRoomControlQueue: Promise = Promise.resolve() +let hostedRoomControlGeneration = 0 let hostedRoomSyncTimer: ReturnType | null = null let hostedRoomSyncRunning = false let hostedRoomSyncDisposed = true @@ -210,6 +215,13 @@ function record(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null } +async function roomControlRequestId(roomId: string, memberId: string): Promise { + const material = new TextEncoder().encode(`room-control-v1\0${roomId}\0${memberId}`) + const digest = await globalThis.crypto.subtle.digest('SHA-256', material) + + return `room-control:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')}` +} + function activeConnectionId() { return String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || '') } @@ -338,6 +350,139 @@ function hostedRoomContinuityMode(room: HostedRoomServerState) { : ('gateway' as const) } +async function ensureReciprocalRoomControls( + room: HostedRoomServerState, + homeRoute: ProfileRoute, + routes: Record, + capabilities: Record, + generation: number +) { + const isCurrent = () => !hostedRoomSyncDisposed && generation === hostedRoomControlGeneration + const roomId = String(room.room_id || '') + const authorityEpoch = Number(room.authority_epoch || 0) + const homeConnectionId = String(homeRoute.connectionId || '') + + if ( + !isCurrent() || + !roomId || + !Number.isSafeInteger(authorityEpoch) || + authorityEpoch < 1 || + capabilities[homeConnectionId]?.reciprocalControl !== true + ) { + return + } + + for (const raw of Array.isArray(room.members) ? room.members : []) { + const member = (record(raw) || {}) as HostedRoomServerMember + const target = record(member.target) + + if (target?.kind !== 'peer') { + continue + } + + const memberId = String(member.member_id || '') + const targetProfile = String(member.profile || member.member_id || 'default') + const targetAuthority = String(target.installation_id || target.peer_id || '') + + const peerConnectionId = Object.entries(capabilities).find( + ([, capability]) => capability.authorityId === targetAuthority + )?.[0] + + const peerRoute = peerConnectionId ? routes[peerConnectionId] : null + const key = `${roomId}:${authorityEpoch}:${memberId}:${targetAuthority}` + + if ( + !memberId || + !targetAuthority || + !peerConnectionId || + !peerRoute || + capabilities[peerConnectionId]?.reciprocalControl !== true || + hostedRoomControlEnrolled.has(key) || + Number(hostedRoomControlRetryAfter.get(key) || 0) > Date.now() + ) { + continue + } + + try { + const control = record( + await requestHostedConnection(homeRoute, 'groups.control.invite', { + room_id: roomId, + member_id: memberId, + caller_install_id: targetAuthority, + request_id: await roomControlRequestId(roomId, memberId) + }) + ) + + if (!isCurrent()) { + return + } + + if ( + !control?.control_token || + !control.home_url || + !control.authority_gateway_id || + !control.authority_epoch || + !control.room_name || + !control.member_count || + !control.expires_at + ) { + throw new Error('Group Chat control invitation is incomplete.') + } + + await requestHostedConnection(peerRoute, 'groups.control.register', { + room_id: roomId, + member_id: memberId, + authority_gateway_id: control.authority_gateway_id, + authority_epoch: control.authority_epoch, + room_name: control.room_name, + member_count: control.member_count, + profile: targetProfile, + home_url: control.home_url, + control_token: control.control_token, + expires_at: control.expires_at + }) + + if (!isCurrent()) { + return + } + hostedRoomControlEnrolled.add(key) + hostedRoomControlRetryAfter.delete(key) + } catch { + if (isCurrent()) { + hostedRoomControlRetryAfter.set(key, Date.now() + 30_000) + } + } + } +} + +function scheduleReciprocalRoomControls( + room: HostedRoomServerState, + homeRoute: ProfileRoute, + routes: Record, + capabilities: Record +) { + const key = `${String(room.room_id || '')}:${Number(room.authority_epoch || 0)}` + + if (hostedRoomControlPending.has(key)) { + return + } + + const generation = hostedRoomControlGeneration + hostedRoomControlPending.set(key, generation) + hostedRoomControlQueue = hostedRoomControlQueue + .then(async () => { + if (!hostedRoomSyncDisposed && generation === hostedRoomControlGeneration) { + await ensureReciprocalRoomControls(room, homeRoute, routes, capabilities, generation) + } + }) + .catch(() => undefined) + .finally(() => { + if (hostedRoomControlPending.get(key) === generation) { + hostedRoomControlPending.delete(key) + } + }) +} + function markHostedConnectionUnavailable(connectionId: string, unsupported = false) { const connectionName = sourceLabel(connectionId) @@ -371,7 +516,10 @@ export function hostedRoomDriverDisplayStatus( driverValue: unknown, { stopping = false }: { stopping?: boolean } = {} ): FriendlyHostedRoomStatus { - if (stopping) { + const driver = record(driverValue) + const counts = record(driver?.counts) + + if (stopping || Number(counts?.stopping || 0) > 0) { return { ...replay, kind: 'stopping', canStop: false } } @@ -379,9 +527,6 @@ export function hostedRoomDriverDisplayStatus( return replay } - const driver = record(driverValue) - const counts = record(driver?.counts) - if (Number(counts?.queued || driver?.queued || 0) > 0) { return { ...replay, kind: 'queued', canStop: true } } @@ -462,7 +607,8 @@ function hostedRoomCapabilityFingerprint(capability: HostedRoomCapability | unde capability.authorityId, capability.persistentProcess, capability.exactPeerGrantRevoke, - capability.routeGrantFingerprint + capability.routeGrantFingerprint, + capability.reciprocalControl ]) } @@ -518,6 +664,7 @@ export async function refreshHostedRooms() { try { const routes = await hostedDefaultRoutes() + const routesByConnection = Object.fromEntries(routes.map(route => [String(route.connectionId || ''), route])) const capabilities = { ...$hostedRoomCapabilities.get() @@ -646,6 +793,8 @@ export async function refreshHostedRooms() { continue } + scheduleReciprocalRoomControls(listedRoom, route, routesByConnection, capabilities) + const existingEntry = Object.entries($groupChats.get()).find( ([, room]) => String(room?.roomId || '') === roomId ) @@ -1084,7 +1233,8 @@ export async function dispatchHostedRoomOutbox() { : command.kind === 'retry' ? { room_id: command.roomId, - task_id: command.payload.task_id + task_id: command.payload.task_id, + command_id: command.commandId } : command.kind === 'stop' || command.kind === 'disband' ? { @@ -1387,6 +1537,9 @@ export async function createAutonomousHostedGroupChat({ } }) peerRegistrations.push({ + member: item.member, + connection_id: connectionId, + caller_install_id: catalog.installation_id, room_id: roomId, member_id: memberId, target_url: scopedTargetUrl, @@ -1404,7 +1557,54 @@ export async function createAutonomousHostedGroupChat({ }) for (const registration of peerRegistrations) { - await requestHostedConnection(homeRoute, 'groups.peer.register', registration) + const member = registration.member as GroupMember + const homeControl = probe.capabilities[homeConnectionId]?.reciprocalControl === true + const peerControl = probe.capabilities[String(registration.connection_id || '')]?.reciprocalControl === true + + if (homeControl && peerControl) { + const control = record( + await requestHostedConnection(homeRoute, 'groups.control.invite', { + room_id: roomId, + member_id: registration.member_id, + caller_install_id: registration.caller_install_id, + request_id: await roomControlRequestId(roomId, String(registration.member_id)) + }) + ) + + if ( + !control?.control_token || + !control.home_url || + !control.authority_gateway_id || + !control.authority_epoch || + !control.room_name || + !control.member_count || + !control.expires_at + ) { + throw new Error('One selected Bot could not prepare remote Group Chat control.') + } + + await requestForBot(member, 'groups.control.register', { + room_id: roomId, + member_id: registration.member_id, + authority_gateway_id: control.authority_gateway_id, + authority_epoch: control.authority_epoch, + room_name: control.room_name, + member_count: control.member_count, + profile: registration.target_profile, + home_url: control.home_url, + control_token: control.control_token, + expires_at: control.expires_at + }) + } + + await requestHostedConnection(homeRoute, 'groups.peer.register', { + room_id: registration.room_id, + member_id: registration.member_id, + target_url: registration.target_url, + target_profile: registration.target_profile, + grant: registration.grant, + catalog: registration.catalog + }) } await releaseHostedRoomCleanup(roomId) @@ -1414,6 +1614,14 @@ export async function createAutonomousHostedGroupChat({ continuityMode: plan.kind === 'multi-gateway' ? ('distributed' as const) : ('gateway' as const) } } catch (error) { + await Promise.allSettled( + peerRegistrations.map(registration => + requestForBot(registration.member as GroupMember, 'groups.control.revoke', { + room_id: roomId, + member_id: registration.member_id + }) + ) + ) await armHostedRoomCleanup(roomId).catch(() => undefined) await dispatchHostedRoomCleanup().catch(() => undefined) @@ -1589,6 +1797,11 @@ export async function startHostedRoomRuntime(storage: PluginContext['storage'], hostedRoomMutationGenerations.clear() hostedRoomLocallyDeleted.clear() hostedRoomInventoriedConnections.clear() + hostedRoomControlGeneration += 1 + hostedRoomControlEnrolled.clear() + hostedRoomControlPending.clear() + hostedRoomControlRetryAfter.clear() + hostedRoomControlQueue = Promise.resolve() let persisted: unknown = null try { @@ -1620,6 +1833,7 @@ export function stopHostedRoomRuntime() { hostedRoomLifecycleGeneration += 1 hostedRoomSyncDisposed = true stopHostedRoomCleanup() + hostedRoomControlGeneration += 1 hostedRoomStorage = null hostedRoomHooks = {} hostedAuthorityRoutes.clear() @@ -1628,6 +1842,10 @@ export function stopHostedRoomRuntime() { hostedRoomMutationGenerations.clear() hostedRoomLocallyDeleted.clear() hostedRoomInventoriedConnections.clear() + hostedRoomControlEnrolled.clear() + hostedRoomControlPending.clear() + hostedRoomControlRetryAfter.clear() + hostedRoomControlQueue = Promise.resolve() hostedUnsupportedUntil.clear() if (hostedRoomSyncTimer) { diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.tsx b/apps/desktop/src/plugins/hermes-bots/plugin.tsx index 26bc2f43f8623..016c4639f4346 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.tsx +++ b/apps/desktop/src/plugins/hermes-bots/plugin.tsx @@ -42,6 +42,7 @@ import { migrateBotMeta, resolveRosterMentions } from './data' +import { startDesktopRoomCommandRuntime, stopDesktopRoomCommandRuntime } from './desktop-room-command-runtime' import { $groupChats, $groupChatWorkspace, @@ -124,6 +125,7 @@ export default { hostedAlreadyRenamed: true }) }) + void startDesktopRoomCommandRuntime(ctx.storage) } startFaceClock() @@ -140,6 +142,7 @@ export default { ctx.onDispose(() => { roomServicesDisposed = true stopHostedRoomRuntime() + stopDesktopRoomCommandRuntime() }) } @@ -274,6 +277,18 @@ export default { holds: room.holds && typeof room.holds === 'object' ? room.holds : {}, members: Array.isArray(room.members) ? room.members : [], roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, + desktopCoordinatorId: + typeof room.desktopCoordinatorId === 'string' && room.desktopCoordinatorId + ? room.desktopCoordinatorId + : null, + desktopAuthorityToken: + typeof room.desktopAuthorityToken === 'string' && room.desktopAuthorityToken + ? room.desktopAuthorityToken + : null, + desktopCommandSettled: + room.desktopCommandSettled && typeof room.desktopCommandSettled === 'object' + ? room.desktopCommandSettled + : {}, hosted: typeof room.hosted === 'string' && room.hosted ? room.hosted : null, hostedEpoch: Math.max(0, Number(room.hostedEpoch || 0)) || null, hostedConnectionId: diff --git a/apps/desktop/src/plugins/hermes-bots/types.ts b/apps/desktop/src/plugins/hermes-bots/types.ts index 0ac8b580ec05d..ac3aeb24c8298 100644 --- a/apps/desktop/src/plugins/hermes-bots/types.ts +++ b/apps/desktop/src/plugins/hermes-bots/types.ts @@ -140,6 +140,8 @@ export interface GroupMessage { at: number /** Stable gateway event identity after a hosted-room replay. */ eventId?: string + /** True for an idempotent message accepted through a messaging bridge. */ + external?: boolean from: GroupMessageAuthor id?: string images?: Attachment[] @@ -158,6 +160,14 @@ export interface GroupHold { export interface GroupChat { /** User-facing continuity choice. Missing records are classic Desktop rooms. */ continuityMode?: 'desktop' | 'distributed' | 'gateway' + /** Public SHA-256 commitment to the local authority token. */ + desktopAuthorityHash?: null | string + /** Secret held only by the Desktop that coordinates this classic room. */ + desktopAuthorityToken?: null | string + /** Stable installation/window identity that owns classic room commands. */ + desktopCoordinatorId?: null | string + /** Bounded idempotency receipts for messaging commands already settled. */ + desktopCommandSettled?: Record /** Bumped to abandon in-flight member turns from a previous round. */ epoch?: number holds?: Record diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 9ec40732b6b70..1fc6070c87ecd 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -171,6 +171,7 @@ present (may be `null`); the rest are included only when set. | `platform` | string | yes | Platform name (matches the descriptor's `platform`). | | `chat_id` | string | yes | Primary conversation id (channel/chat). Session-key discriminator. | | `chat_type` | string | yes | `dm` / `group` / `channel` / `thread` / `forum`. | +| `one_to_one_verified` | boolean | no | Connector-verified private conversation with exactly one human. Required for private controls on relay-fronted platforms where `dm` can contain multiple people (for example Slack or Matrix). The gateway treats it as a transport-local fact and never persists it. | | `chat_name` | string\|null | yes | Human-readable chat name. | | `user_id` | string\|null | yes | Message author id. Session-key discriminator. | | `user_name` | string\|null | yes | Author display name. | @@ -182,11 +183,12 @@ present (may be `null`); the rest are included only when set. | `guild_id` | string | no | **Legacy alias, no longer read by the connector.** As of D-Q2.5c the connector reads and writes only `scope_id`; the gateway's agent-wide `SessionSource.to_dict()` still emits `guild_id` (mirrored to `scope_id`) for non-relay session persistence, so it may still appear on the wire but the connector ignores it. Do not depend on it. | | `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. | | `message_id` | string | no | Id of the triggering message (for pin/reply/react). | +| `is_bot` | boolean | yes | Whether the author is a bot or webhook. Room controls fail closed when an older connector omits this classification. | +| `message_is_edit` | boolean | yes | Whether this delivery edits a prior message. Room controls fail closed when an older connector omits this classification. | -> `is_bot` (author-is-a-bot/webhook classification) exists on the gateway-side -> dataclass but is **intentionally NOT on the wire** in v1 — it is not part of -> `to_dict()`. Do not add it to the connector's `SessionSource` until it is -> first added here and to `to_dict()` (additive bump). +`is_bot` is an additive author-provenance bump. Updated connectors must send an +explicit `false` for people rather than omitting the field; omission remains +accepted by older gateway features but room controls reject it. ### SessionSource discriminators per platform diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index e80435d9827f9..9cd0f2b7abbb9 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -104,6 +104,67 @@ def _coerce_allow_set(raw) -> set[str]: return {part.strip() for part in str(raw).split(",") if part.strip()} +_PLATFORM_ALLOWED_USERS_ENV = { + Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", + Platform.DISCORD: "DISCORD_ALLOWED_USERS", + Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", + Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS", + Platform.SLACK: "SLACK_ALLOWED_USERS", + Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", + Platform.EMAIL: "EMAIL_ALLOWED_USERS", + Platform.SMS: "SMS_ALLOWED_USERS", + Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS", + Platform.MATRIX: "MATRIX_ALLOWED_USERS", + Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", + Platform.FEISHU: "FEISHU_ALLOWED_USERS", + Platform.WECOM: "WECOM_ALLOWED_USERS", + Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS", + Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", + Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", + Platform.QQBOT: "QQ_ALLOWED_USERS", + Platform.YUANBAO: "YUANBAO_ALLOWED_USERS", +} + +_PLATFORM_ALLOW_ALL_ENV = { + Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", + Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS", + Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS", + Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOW_ALL_USERS", + Platform.SLACK: "SLACK_ALLOW_ALL_USERS", + Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", + Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS", + Platform.SMS: "SMS_ALLOW_ALL_USERS", + Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS", + Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS", + Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS", + Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS", + Platform.WECOM: "WECOM_ALLOW_ALL_USERS", + Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS", + Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", + Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", + Platform.QQBOT: "QQ_ALLOW_ALL_USERS", + Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS", +} + + +def _platform_authorization_env_names(platform: Platform) -> tuple[str, str]: + """Return the authoritative allowed-users and allow-all env names.""" + allowed_users = _PLATFORM_ALLOWED_USERS_ENV.get(platform, "") + allow_all = _PLATFORM_ALLOW_ALL_ENV.get(platform, "") + if allowed_users and allow_all: + return allowed_users, allow_all + try: + from gateway.platform_registry import platform_registry + + entry = platform_registry.get(platform.value) + if entry is not None: + allowed_users = allowed_users or entry.allowed_users_env + allow_all = allow_all or entry.allow_all_env + except Exception: + pass + return allowed_users, allow_all + + # --------------------------------------------------------------------------- # Nostr npub → hex normalization (Buzz and future Nostr-based platforms). # @@ -614,26 +675,9 @@ def _is_user_authorized( if not user_id: return False - platform_env_map = { - Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", - Platform.DISCORD: "DISCORD_ALLOWED_USERS", - Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", - Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS", - Platform.SLACK: "SLACK_ALLOWED_USERS", - Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", - Platform.EMAIL: "EMAIL_ALLOWED_USERS", - Platform.SMS: "SMS_ALLOWED_USERS", - Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS", - Platform.MATRIX: "MATRIX_ALLOWED_USERS", - Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", - Platform.FEISHU: "FEISHU_ALLOWED_USERS", - Platform.WECOM: "WECOM_ALLOWED_USERS", - Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS", - Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", - Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", - Platform.QQBOT: "QQ_ALLOWED_USERS", - Platform.YUANBAO: "YUANBAO_ALLOWED_USERS", - } + platform_allowed_users_var, platform_allow_all_var = ( + _platform_authorization_env_names(source.platform) + ) platform_group_user_env_map = { Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS", } @@ -641,43 +685,7 @@ def _is_user_authorized( Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", } - platform_allow_all_map = { - Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", - Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS", - Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS", - Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOW_ALL_USERS", - Platform.SLACK: "SLACK_ALLOW_ALL_USERS", - Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", - Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS", - Platform.SMS: "SMS_ALLOW_ALL_USERS", - Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS", - Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS", - Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS", - Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS", - Platform.WECOM: "WECOM_ALLOW_ALL_USERS", - Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS", - Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", - Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", - Platform.QQBOT: "QQ_ALLOW_ALL_USERS", - Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS", - } - - # Plugin platforms: check the registry for auth env var names - if source.platform not in platform_env_map: - try: - from gateway.platform_registry import platform_registry - - entry = platform_registry.get(source.platform.value) - if entry: - if entry.allowed_users_env: - platform_env_map[source.platform] = entry.allowed_users_env - if entry.allow_all_env: - platform_allow_all_map[source.platform] = entry.allow_all_env - except Exception: - pass - # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) - platform_allow_all_var = platform_allow_all_map.get(source.platform, "") if platform_allow_all_var and _auth_env(platform_allow_all_var).lower() in {"true", "1", "yes"}: return True @@ -712,7 +720,7 @@ def _is_user_authorized( return True # Check platform-specific and global allowlists - platform_allowlist = _auth_env(platform_env_map.get(source.platform, "")) + platform_allowlist = _auth_env(platform_allowed_users_var) group_user_allowlist = "" group_chat_allowlist = "" if source.chat_type in {"group", "forum"}: diff --git a/gateway/desktop_room_mailbox.py b/gateway/desktop_room_mailbox.py new file mode 100644 index 0000000000000..91d6ebebf5635 --- /dev/null +++ b/gateway/desktop_room_mailbox.py @@ -0,0 +1,1097 @@ +"""Durable command handoff for Desktop-driven Bot rooms. + +Messaging adapters run on the gateway while classic room orchestration lives +in a connected Desktop renderer. This mailbox keeps that compatibility path +idempotent and recoverable without making the gateway a second room runner. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import secrets +import sqlite3 +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + + +MAX_ROOM_IDS = 128 +MAX_QUERY_ROOM_IDS = 4096 +MAX_COMMANDS_PER_CLAIM = 8 +MAX_PAYLOAD_BYTES = 64 * 1024 +# Desktop refreshes classic-room presence on a 60s retained-socket backstop; +# push events handle command latency. Keep enough overlap for scheduler jitter +# without turning a closed Desktop into a long-lived false-positive. +PRESENCE_TTL_SECONDS = 90.0 +CLAIM_TTL_SECONDS = 45.0 +PENDING_TTL_SECONDS = 24 * 60 * 60 +TERMINAL_RETENTION_SECONDS = 7 * 24 * 60 * 60 +MAX_PENDING_COMMANDS_PER_ROOM = 64 +MAX_PENDING_COMMANDS_TOTAL = 4096 +MAX_RETRY_COMMANDS = 32 + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") +_AUTHORITY_HASH_RE = re.compile(r"^[a-f0-9]{64}$") +_ACTIONS = frozenset({"send", "stop"}) +_TERMINAL_STATES = frozenset({"completed", "failed"}) + + +class DesktopRoomMailboxError(ValueError): + """Raised when a mailbox command is invalid or stale.""" + + +def default_db_path() -> Path: + """Keep compatibility heartbeats out of the session state database.""" + + from gateway.hosted_rooms import default_db_path as hosted_db_path + + return hosted_db_path().with_name("desktop_room_mailbox.db") + + +def pending_signal_path(db_path: Path | str | None = None) -> Path: + """Cross-process change signal watched by the gateway WebSocket server.""" + + return Path(db_path or default_db_path()).with_name("desktop_room_mailbox.pending") + + +def _identifier(value: Any, *, label: str) -> str: + text = str(value or "").strip() + if not text or len(text) > 160 or not _IDENTIFIER_RE.fullmatch(text): + raise DesktopRoomMailboxError(f"invalid {label}") + return text + + +def _room_identifier(value: Any) -> str: + text = str(value or "").strip() + if ( + not text + or len(text) > 200 + or any(char in text for char in ("\x00", "\r", "\n")) + ): + raise DesktopRoomMailboxError("invalid room_id") + return text + + +def _room_ids(value: Any) -> list[str]: + if not isinstance(value, (list, tuple)): + raise DesktopRoomMailboxError("room_ids must be a list") + if len(value) > MAX_ROOM_IDS: + raise DesktopRoomMailboxError("too many room_ids") + return list(dict.fromkeys(_room_identifier(item) for item in value)) + + +def _room_authorities(value: Any) -> list[tuple[str, str]]: + if not isinstance(value, (list, tuple)): + raise DesktopRoomMailboxError("room_authorities must be a list") + if len(value) > MAX_ROOM_IDS: + raise DesktopRoomMailboxError("too many room authorities") + authorities: dict[str, str] = {} + for item in value: + if not isinstance(item, dict): + raise DesktopRoomMailboxError("invalid room authority") + room_id = _room_identifier(item.get("room_id")) + token = _identifier(item.get("authority_token"), label="authority_token") + digest = hashlib.sha256(token.encode("utf-8")).hexdigest() + prior = authorities.setdefault(room_id, digest) + if prior != digest: + raise DesktopRoomMailboxError("conflicting room authority") + return list(authorities.items()) + + +def _authority_hash(value: Any) -> str: + authority_hash = str(value or "").strip().casefold() + if not _AUTHORITY_HASH_RE.fullmatch(authority_hash): + raise DesktopRoomMailboxError("invalid room authority commitment") + return authority_hash + + +def _room_commitments(value: Any) -> list[tuple[str, str]]: + if not isinstance(value, (list, tuple)): + raise DesktopRoomMailboxError("room commitments must be a list") + if len(value) > MAX_ROOM_IDS: + raise DesktopRoomMailboxError("too many room commitments") + commitments: dict[str, str] = {} + for item in value: + if not isinstance(item, dict): + raise DesktopRoomMailboxError("invalid room commitment") + room_id = _room_identifier(item.get("room_id")) + authority_hash = _authority_hash(item.get("authority_hash")) + prior = commitments.setdefault(room_id, authority_hash) + if prior != authority_hash: + raise DesktopRoomMailboxError("conflicting room commitment") + return list(commitments.items()) + + +def _query_room_ids(value: Any) -> list[str]: + if not isinstance(value, (list, tuple)): + raise DesktopRoomMailboxError("room_ids must be a list") + if len(value) > MAX_QUERY_ROOM_IDS: + raise DesktopRoomMailboxError("too many room_ids") + return list(dict.fromkeys(_room_identifier(item) for item in value)) + + +def _payload_json(value: Any) -> str: + try: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + except (TypeError, ValueError, RecursionError) as exc: + raise DesktopRoomMailboxError("payload must be JSON-serializable") from exc + if len(encoded.encode("utf-8")) > MAX_PAYLOAD_BYTES: + raise DesktopRoomMailboxError("payload is too large") + return encoded + + +def _initialize(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS desktop_room_commands ( + command_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + action TEXT NOT NULL, + payload_json TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + lease_owner TEXT, + lease_token TEXT, + lease_expires_at REAL, + attempts INTEGER NOT NULL DEFAULT 0, + result_json TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + command_columns = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_commands)") + } + if "lease_token" not in command_columns: + conn.execute("ALTER TABLE desktop_room_commands ADD COLUMN lease_token TEXT") + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_desktop_room_commands_claim + ON desktop_room_commands(state, room_id, created_at)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS desktop_room_presence ( + consumer_id TEXT NOT NULL, + room_id TEXT NOT NULL, + expires_at REAL NOT NULL, + PRIMARY KEY (consumer_id, room_id) + )""" + ) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_desktop_room_presence_room + ON desktop_room_presence(room_id, expires_at)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS desktop_room_owners ( + room_id TEXT PRIMARY KEY, + consumer_id TEXT NOT NULL, + expires_at REAL NOT NULL + )""" + ) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_desktop_room_owners_expiry + ON desktop_room_owners(expires_at)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS desktop_room_authorities ( + room_id TEXT PRIMARY KEY, + consumer_id TEXT, + authority_hash TEXT NOT NULL, + created_at REAL NOT NULL + )""" + ) + authority_columns = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_authorities)") + } + if "consumer_id" not in authority_columns: + conn.execute( + "ALTER TABLE desktop_room_authorities ADD COLUMN consumer_id TEXT" + ) + + +def _schema_is_current(conn: sqlite3.Connection) -> bool: + commands = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_commands)") + } + presence = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_presence)") + } + owners = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_owners)") + } + authorities = { + row[1] for row in conn.execute("PRAGMA table_info(desktop_room_authorities)") + } + return ( + { + "command_id", + "room_id", + "action", + "payload_json", + "state", + "lease_owner", + "lease_token", + "lease_expires_at", + "attempts", + "result_json", + "created_at", + "updated_at", + }.issubset(commands) + and {"consumer_id", "room_id", "expires_at"}.issubset(presence) + and {"room_id", "consumer_id", "expires_at"}.issubset(owners) + and {"room_id", "consumer_id", "authority_hash", "created_at"}.issubset( + authorities + ) + ) + + +def _connect(db_path: Path | str) -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + + path = Path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=10) + conn.row_factory = sqlite3.Row + try: + apply_wal_with_fallback(conn, db_label="state.db (desktop room mailbox)") + if _schema_is_current(conn): + return conn + conn.execute("BEGIN IMMEDIATE") + _initialize(conn) + conn.commit() + except Exception: + conn.rollback() + conn.close() + raise + return conn + + +def _notify_pending(db_path: Path | str) -> None: + path = pending_signal_path(db_path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(str(time.time_ns()), encoding="ascii") + os.chmod(path, 0o600) + except OSError: + # The command is already durable. A failed best-effort wake signal + # must never turn success into an error or invite a duplicate send; + # the retained-socket poll remains the backstop. + pass + + +@contextmanager +def _transaction( + db_path: Path | str, *, immediate: bool = False +) -> Iterator[sqlite3.Connection]: + conn = _connect(db_path) + try: + if immediate: + conn.execute("BEGIN IMMEDIATE") + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _command(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: + result = { + "command_id": str(row["command_id"]), + "room_id": str(row["room_id"]), + "action": str(row["action"]), + "payload": json.loads(row["payload_json"]), + "state": str(row["state"]), + "attempts": int(row["attempts"]), + "created_at": float(row["created_at"]), + "updated_at": float(row["updated_at"]), + "idempotent": idempotent, + } + if row["result_json"]: + result["result"] = json.loads(row["result_json"]) + if row["lease_token"]: + result["lease_token"] = str(row["lease_token"]) + return result + + +def _expire_stale_state( + conn: sqlite3.Connection, + *, + now: float, + pending_ttl: float = PENDING_TTL_SECONDS, +) -> None: + """Bound offline work and remove expired ownership before every write path.""" + + conn.execute("DELETE FROM desktop_room_presence WHERE expires_at <= ?", (now,)) + conn.execute("DELETE FROM desktop_room_owners WHERE expires_at <= ?", (now,)) + cutoff = now - max(1.0, float(pending_ttl)) + expired_result = _payload_json( + { + "code": "command_expired", + "message": "This Group Chat command expired before Desktop could apply it.", + } + ) + conn.execute( + """UPDATE desktop_room_commands + SET state = 'failed', result_json = ?, lease_owner = NULL, + lease_token = NULL, lease_expires_at = NULL, updated_at = ? + WHERE state = 'pending' AND created_at <= ?""", + (expired_result, now, cutoff), + ) + conn.execute( + """UPDATE desktop_room_commands + SET state = 'failed', result_json = ?, lease_owner = NULL, + lease_token = NULL, lease_expires_at = NULL, updated_at = ? + WHERE state = 'claimed' AND created_at <= ? + AND COALESCE(lease_expires_at, 0) <= ?""", + (expired_result, now, cutoff, now), + ) + conn.execute( + """DELETE FROM desktop_room_commands + WHERE state IN ('completed', 'failed') AND updated_at <= ?""", + (now - TERMINAL_RETENTION_SECONDS,), + ) + + +def _owned_rooms( + conn: sqlite3.Connection, + *, + consumer_id: str, + authorities: list[tuple[str, str]], + now: float, + presence_ttl: float, +) -> list[str]: + """Bind or safely transfer rooms when the prior Desktop lease is gone.""" + + candidates: list[str] = [] + for room_id, authority_hash in authorities: + authority = conn.execute( + """SELECT consumer_id, authority_hash + FROM desktop_room_authorities WHERE room_id = ?""", + (room_id,), + ).fetchone() + if authority is None or str(authority["authority_hash"] or "") != authority_hash: + continue + bound_consumer = str(authority["consumer_id"] or "") + if bound_consumer and bound_consumer != consumer_id: + live_owner = conn.execute( + """SELECT 1 FROM desktop_room_owners + WHERE room_id = ? AND consumer_id = ? AND expires_at > ?""", + (room_id, bound_consumer, now), + ).fetchone() + live_claim = conn.execute( + """SELECT 1 FROM desktop_room_commands + WHERE room_id = ? AND state = 'claimed' + AND lease_owner = ? AND lease_expires_at > ? LIMIT 1""", + (room_id, bound_consumer, now), + ).fetchone() + if live_owner is not None or live_claim is not None: + continue + conn.execute( + """UPDATE desktop_room_authorities SET consumer_id = ? + WHERE room_id = ? AND authority_hash = ?""", + (consumer_id, room_id, authority_hash), + ) + candidates.append(room_id) + + if candidates: + conn.executemany( + """INSERT INTO desktop_room_owners ( + room_id, consumer_id, expires_at + ) VALUES (?, ?, ?) + ON CONFLICT(room_id) DO UPDATE + SET consumer_id = excluded.consumer_id, + expires_at = excluded.expires_at + WHERE desktop_room_owners.consumer_id = excluded.consumer_id + OR desktop_room_owners.expires_at <= ?""", + ( + (room_id, consumer_id, now + float(presence_ttl), now) + for room_id in candidates + ), + ) + owned: list[str] = [] + if candidates: + placeholders = ",".join("?" for _ in candidates) + owned = [ + str(row["room_id"]) + for row in conn.execute( + f"""SELECT room_id FROM desktop_room_owners + WHERE room_id IN ({placeholders}) AND consumer_id = ? + AND expires_at > ?""", + (*candidates, consumer_id, now), + ) + ] + if owned: + conn.executemany( + """INSERT INTO desktop_room_presence ( + consumer_id, room_id, expires_at + ) VALUES (?, ?, ?) + ON CONFLICT(consumer_id, room_id) DO UPDATE + SET expires_at = excluded.expires_at""", + ( + (consumer_id, room_id, now + float(presence_ttl)) + for room_id in owned + ), + ) + return owned + + +def register_projected_authorities( + db_path: Path | str, + commitments: Any, + *, + clock: Any = time.time, +) -> list[str]: + """Record one-way owner proofs read from the trusted room projection. + + Conflicts are isolated per room: an old or corrupted projection cannot + prevent healthy rooms in the same snapshot from advertising presence. + """ + + parsed = _room_commitments(commitments) + now = float(clock()) + registered: list[str] = [] + with _transaction(db_path, immediate=True) as conn: + for room_id, authority_hash in parsed: + existing = conn.execute( + """SELECT authority_hash FROM desktop_room_authorities + WHERE room_id = ?""", + (room_id,), + ).fetchone() + if existing is None: + conn.execute( + """INSERT INTO desktop_room_authorities ( + room_id, consumer_id, authority_hash, created_at + ) VALUES (?, NULL, ?, ?)""", + (room_id, authority_hash, now), + ) + registered.append(room_id) + elif str(existing["authority_hash"] or "") == authority_hash: + registered.append(room_id) + return registered + + +def enqueue_command( + db_path: Path | str, + *, + command_id: str, + room_id: str, + authority_hash: str, + action: str, + payload: Any, + clock: Any = time.time, +) -> dict[str, Any]: + """Persist one idempotent command for a compatible Desktop.""" + + command_id = _identifier(command_id, label="command_id") + room_id = _room_identifier(room_id) + authority_hash = _authority_hash(authority_hash) + action = str(action or "").strip().casefold() + if action not in _ACTIONS: + raise DesktopRoomMailboxError("invalid action") + encoded = _payload_json(payload) + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + _expire_stale_state(conn, now=now) + existing_authority = conn.execute( + """SELECT authority_hash FROM desktop_room_authorities + WHERE room_id = ?""", + (room_id,), + ).fetchone() + if existing_authority is None: + conn.execute( + """INSERT INTO desktop_room_authorities ( + room_id, consumer_id, authority_hash, created_at + ) VALUES (?, NULL, ?, ?)""", + (room_id, authority_hash, now), + ) + elif str(existing_authority["authority_hash"] or "") != authority_hash: + raise DesktopRoomMailboxError( + "room authority commitment does not match its existing owner" + ) + existing = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (command_id,), + ).fetchone() + if existing is not None: + if ( + str(existing["room_id"]) != room_id + or str(existing["action"]) != action + or str(existing["payload_json"]) != encoded + ): + raise DesktopRoomMailboxError( + "command_id was already used for different room work" + ) + result = _command(existing, idempotent=True) + else: + if action == "stop": + superseded_result = _payload_json( + { + "code": "superseded_by_stop", + "message": "Canceled before Desktop started it.", + } + ) + conn.execute( + """UPDATE desktop_room_commands + SET state = 'failed', result_json = ?, lease_owner = NULL, + lease_token = NULL, lease_expires_at = NULL, updated_at = ? + WHERE room_id = ? AND action IN ('send', 'stop') + AND state = 'pending'""", + (superseded_result, now, room_id), + ) + else: + room_count = conn.execute( + """SELECT COUNT(*) FROM desktop_room_commands + WHERE room_id = ? AND state IN ('pending', 'claimed')""", + (room_id,), + ).fetchone()[0] + total_count = conn.execute( + """SELECT COUNT(*) FROM desktop_room_commands + WHERE state IN ('pending', 'claimed')""" + ).fetchone()[0] + if int(room_count) >= MAX_PENDING_COMMANDS_PER_ROOM: + raise DesktopRoomMailboxError( + "This Group Chat already has too many commands waiting for Desktop." + ) + if int(total_count) >= MAX_PENDING_COMMANDS_TOTAL: + raise DesktopRoomMailboxError( + "Too many Group Chat commands are waiting for Desktop." + ) + conn.execute( + """INSERT INTO desktop_room_commands ( + command_id, room_id, action, payload_json, state, + created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', ?, ?)""", + (command_id, room_id, action, encoded, now, now), + ) + row = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (command_id,), + ).fetchone() + result = _command(row) + _notify_pending(db_path) + return result + + +def claim_commands( + db_path: Path | str, + *, + consumer_id: str, + room_authorities: Any, + actions: Any = None, + limit: int = MAX_COMMANDS_PER_CLAIM, + presence_ttl: float = PRESENCE_TTL_SECONDS, + claim_ttl: float = CLAIM_TTL_SECONDS, + clock: Any = time.time, +) -> list[dict[str, Any]]: + """Refresh room presence and lease pending commands to one Desktop.""" + + consumer_id = _identifier(consumer_id, label="consumer_id") + authorities = _room_authorities(room_authorities) + requested_actions = ( + {str(action or "").strip().casefold() for action in actions} + if isinstance(actions, (list, tuple, set, frozenset)) + else set() + ) + if requested_actions and not requested_actions.issubset(_ACTIONS): + raise DesktopRoomMailboxError("invalid action filter") + limit = max(1, min(MAX_COMMANDS_PER_CLAIM, int(limit))) + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + _expire_stale_state(conn, now=now) + owned = _owned_rooms( + conn, + consumer_id=consumer_id, + authorities=authorities, + now=now, + presence_ttl=presence_ttl, + ) + if not owned: + return [] + placeholders = ",".join("?" for _ in owned) + action_sql = "" + action_params: tuple[str, ...] = () + if requested_actions: + action_placeholders = ",".join("?" for _ in requested_actions) + action_sql = f" AND command.action IN ({action_placeholders})" + action_params = tuple(sorted(requested_actions)) + rows = conn.execute( + f"""SELECT command.* FROM desktop_room_commands AS command + WHERE command.room_id IN ({placeholders}) + {action_sql} + AND ( + command.state = 'pending' + OR (command.state = 'claimed' AND COALESCE(command.lease_expires_at, 0) <= ?) + ) + ORDER BY CASE command.action WHEN 'stop' THEN 0 ELSE 1 END, + command.created_at, command.command_id + LIMIT ?""", + (*owned, *action_params, now, limit), + ).fetchall() + claimed: list[dict[str, Any]] = [] + for row in rows: + lease_token = secrets.token_hex(16) + updated = conn.execute( + """UPDATE desktop_room_commands + SET state = 'claimed', lease_owner = ?, lease_token = ?, + lease_expires_at = ?, attempts = attempts + 1, + updated_at = ? + WHERE command_id = ? + AND ( + state = 'pending' + OR (state = 'claimed' AND COALESCE(lease_expires_at, 0) <= ?) + )""", + ( + consumer_id, + lease_token, + now + float(claim_ttl), + now, + str(row["command_id"]), + now, + ), + ) + if updated.rowcount != 1: + continue + current = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (str(row["command_id"]),), + ).fetchone() + command = _command(current) + if str(current["action"]) == "stop": + target_command_id = str( + command.get("payload", {}).get("target_command_id") or "" + ) + if target_command_id: + target = conn.execute( + "SELECT state, result_json FROM desktop_room_commands WHERE command_id = ?", + (target_command_id,), + ).fetchone() + if target is not None: + command["target_command_state"] = str(target["state"]) + target_result = ( + json.loads(target["result_json"]) + if target["result_json"] + else {} + ) + if isinstance(target_result, dict) and target_result.get("code"): + command["target_result_code"] = str(target_result["code"]) + claimed.append(command) + return claimed + + +def refresh_presence( + db_path: Path | str, + *, + consumer_id: str, + room_authorities: Any, + presence_ttl: float = PRESENCE_TTL_SECONDS, + clock: Any = time.time, +) -> list[str]: + """Renew classic Group Chat ownership without leasing queued commands.""" + + consumer_id = _identifier(consumer_id, label="consumer_id") + authorities = _room_authorities(room_authorities) + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + _expire_stale_state(conn, now=now) + return _owned_rooms( + conn, + consumer_id=consumer_id, + authorities=authorities, + now=now, + presence_ttl=presence_ttl, + ) + + +def complete_command( + db_path: Path | str, + *, + consumer_id: str, + command_id: str, + lease_token: str, + success: bool, + result: Any, + clock: Any = time.time, +) -> dict[str, Any]: + """Commit one claimed command result, tolerating an ACK retry.""" + + consumer_id = _identifier(consumer_id, label="consumer_id") + command_id = _identifier(command_id, label="command_id") + lease_token = _identifier(lease_token, label="lease_token") + encoded = _payload_json(result) + state = "completed" if success else "failed" + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + row = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (command_id,), + ).fetchone() + if row is None: + raise DesktopRoomMailboxError("command not found") + if str(row["state"]) in _TERMINAL_STATES: + if str(row["state"]) == state and str(row["result_json"] or "") == encoded: + return _command(row, idempotent=True) + raise DesktopRoomMailboxError("command already has a different result") + if ( + str(row["state"]) != "claimed" + or str(row["lease_owner"] or "") != consumer_id + or str(row["lease_token"] or "") != lease_token + or float(row["lease_expires_at"] or 0) <= now + ): + raise DesktopRoomMailboxError( + "command lease is no longer owned by this Desktop" + ) + updated = conn.execute( + """UPDATE desktop_room_commands + SET state = ?, result_json = ?, lease_owner = NULL, + lease_token = NULL, lease_expires_at = NULL, updated_at = ? + WHERE command_id = ? AND state = 'claimed' AND lease_owner = ? + AND lease_token = ? AND lease_expires_at > ?""", + (state, encoded, now, command_id, consumer_id, lease_token, now), + ) + if updated.rowcount != 1: + raise DesktopRoomMailboxError("command completion raced another consumer") + current = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (command_id,), + ).fetchone() + return _command(current) + + +def renew_command( + db_path: Path | str, + *, + consumer_id: str, + command_id: str, + lease_token: str, + claim_ttl: float = CLAIM_TTL_SECONDS, + presence_ttl: float = PRESENCE_TTL_SECONDS, + clock: Any = time.time, +) -> dict[str, Any]: + """Extend one live claim without allowing an expired attempt to revive.""" + + consumer_id = _identifier(consumer_id, label="consumer_id") + command_id = _identifier(command_id, label="command_id") + lease_token = _identifier(lease_token, label="lease_token") + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + row = conn.execute( + """SELECT room_id FROM desktop_room_commands + WHERE command_id = ? AND state = 'claimed' + AND lease_owner = ? AND lease_token = ? + AND lease_expires_at > ?""", + (command_id, consumer_id, lease_token, now), + ).fetchone() + if row is None: + raise DesktopRoomMailboxError( + "command lease is no longer owned by this Desktop" + ) + room_id = str(row["room_id"]) + owner = conn.execute( + """UPDATE desktop_room_owners + SET expires_at = ? + WHERE room_id = ? AND consumer_id = ? AND expires_at > ?""", + (now + float(presence_ttl), room_id, consumer_id, now), + ) + if owner.rowcount != 1: + raise DesktopRoomMailboxError( + "room authority is no longer owned by this Desktop" + ) + updated = conn.execute( + """UPDATE desktop_room_commands + SET lease_expires_at = ?, updated_at = ? + WHERE command_id = ? AND state = 'claimed' + AND lease_owner = ? AND lease_token = ? + AND lease_expires_at > ?""", + ( + now + float(claim_ttl), + now, + command_id, + consumer_id, + lease_token, + now, + ), + ) + if updated.rowcount != 1: + raise DesktopRoomMailboxError( + "command lease is no longer owned by this Desktop" + ) + conn.execute( + """INSERT INTO desktop_room_presence ( + consumer_id, room_id, expires_at + ) VALUES (?, ?, ?) + ON CONFLICT(consumer_id, room_id) DO UPDATE + SET expires_at = excluded.expires_at""", + (consumer_id, room_id, now + float(presence_ttl)), + ) + current = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (command_id,), + ).fetchone() + return _command(current) + + +def retry_failed_command( + db_path: Path | str, + *, + room_id: Any, + command_id: Any = None, + clock: Any = time.time, +) -> dict[str, Any]: + """Requeue one exact failed Desktop command after explicit owner action.""" + + room_id = _room_identifier(room_id) + requested_id = str(command_id or "").strip() + now = float(clock()) + with _transaction(db_path, immediate=True) as conn: + if requested_id: + row = conn.execute( + """SELECT * FROM desktop_room_commands + WHERE room_id = ? AND command_id = ?""", + (room_id, _identifier(requested_id, label="command_id")), + ).fetchone() + else: + row = conn.execute( + """SELECT * FROM desktop_room_commands + WHERE room_id = ? AND state IN ('failed', 'pending') + ORDER BY updated_at DESC, command_id DESC LIMIT 1""", + (room_id,), + ).fetchone() + if row is None: + raise DesktopRoomMailboxError("no failed Group Chat command needs retry") + if str(row["state"]) == "pending": + return _command(row, idempotent=True) + if str(row["state"]) != "failed": + raise DesktopRoomMailboxError("that Group Chat command is not retryable") + conn.execute( + """UPDATE desktop_room_commands + SET state='pending', lease_token=NULL, lease_owner=NULL, + lease_expires_at=NULL, result_json=NULL, + created_at=?, updated_at=? + WHERE command_id=? AND state='failed'""", + (now, now, str(row["command_id"])), + ) + retried = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id = ?", + (str(row["command_id"]),), + ).fetchone() + if retried is None: + raise DesktopRoomMailboxError("Group Chat command disappeared during retry") + result = _command(retried) + _notify_pending(db_path) + return result + + +def retry_failed_commands( + db_path: Path | str, + *, + room_id: Any, + command_ids: Any = None, + clock: Any = time.time, +) -> list[dict[str, Any]]: + """Requeue a bounded oldest-first batch after explicit owner confirmation.""" + + room_id = _room_identifier(room_id) + now = float(clock()) + retried: list[dict[str, Any]] = [] + requested = tuple( + _identifier(str(item), label="command_id") + for item in (command_ids or ()) + if str(item).strip() + ) + if len(requested) > MAX_RETRY_COMMANDS: + raise DesktopRoomMailboxError("too many Group Chat commands to retry") + with _transaction(db_path, immediate=True) as conn: + if requested: + placeholders = ",".join("?" for _ in requested) + rows = conn.execute( + f"""SELECT * FROM desktop_room_commands + WHERE room_id=? AND command_id IN ({placeholders}) + AND state IN ('failed','pending') + ORDER BY created_at, command_id""", + (room_id, *requested), + ).fetchall() + if len(rows) != len(requested): + raise DesktopRoomMailboxError( + "Group Chat retry scope changed before it could be applied" + ) + else: + rows = conn.execute( + """SELECT * FROM desktop_room_commands + WHERE room_id=? AND state='failed' + ORDER BY created_at, command_id + LIMIT ?""", + (room_id, MAX_RETRY_COMMANDS), + ).fetchall() + if not rows: + pending = conn.execute( + """SELECT * FROM desktop_room_commands + WHERE room_id=? AND state='pending' + ORDER BY created_at, command_id LIMIT 1""", + (room_id,), + ).fetchone() + if pending is not None: + return [_command(pending, idempotent=True)] + raise DesktopRoomMailboxError("no failed Group Chat command needs retry") + for index, row in enumerate(rows): + command_id = str(row["command_id"]) + if str(row["state"]) == "pending": + retried.append(_command(row, idempotent=True)) + continue + conn.execute( + """UPDATE desktop_room_commands + SET state='pending', lease_token=NULL, lease_owner=NULL, + lease_expires_at=NULL, result_json=NULL, + created_at=?, updated_at=? + WHERE command_id=? AND state='failed'""", + (now + index * 0.000001, now, command_id), + ) + current = conn.execute( + "SELECT * FROM desktop_room_commands WHERE command_id=?", + (command_id,), + ).fetchone() + if current is None: + raise DesktopRoomMailboxError( + "Group Chat command disappeared during retry" + ) + retried.append(_command(current)) + _notify_pending(db_path) + return retried + + +def retryable_command_ids( + db_path: Path | str, + *, + room_id: Any, +) -> tuple[str, ...]: + """Freeze the bounded oldest-first retry scope before mutating it.""" + + room_id = _room_identifier(room_id) + with _transaction(db_path) as conn: + rows = conn.execute( + """SELECT command_id FROM desktop_room_commands + WHERE room_id=? AND state='failed' + ORDER BY created_at, command_id + LIMIT ?""", + (room_id, MAX_RETRY_COMMANDS), + ).fetchall() + if not rows: + pending = conn.execute( + """SELECT command_id FROM desktop_room_commands + WHERE room_id=? AND state='pending' + ORDER BY created_at, command_id LIMIT 1""", + (room_id,), + ).fetchone() + rows = [pending] if pending is not None else [] + if not rows: + raise DesktopRoomMailboxError("no failed Group Chat command needs retry") + return tuple(str(row["command_id"]) for row in rows) + + +def failed_command_counts( + db_path: Path | str, + room_ids: Any, +) -> dict[str, int]: + """Return bounded failed-command counts for requested Desktop rooms.""" + + rooms = _query_room_ids(room_ids) + if not rooms: + return {} + counts: dict[str, int] = {} + with _transaction(db_path) as conn: + for index in range(0, len(rooms), MAX_ROOM_IDS): + batch = rooms[index : index + MAX_ROOM_IDS] + placeholders = ",".join("?" for _ in batch) + rows = conn.execute( + f"""SELECT room_id, COUNT(*) AS count + FROM desktop_room_commands + WHERE room_id IN ({placeholders}) AND state='failed' + GROUP BY room_id""", + tuple(batch), + ).fetchall() + counts.update( + {str(row["room_id"]): int(row["count"]) for row in rows} + ) + return counts + + +def room_available( + db_path: Path | str, + room_id: str, + *, + clock: Any = time.time, +) -> bool: + """Return whether a connected Desktop currently advertises this room.""" + + room_id = _room_identifier(room_id) + now = float(clock()) + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT 1 FROM desktop_room_presence + WHERE room_id = ? AND expires_at > ? LIMIT 1""", + (room_id, now), + ).fetchone() + return row is not None + + +def available_room_ids( + db_path: Path | str, + room_ids: Any, + *, + clock: Any = time.time, +) -> set[str]: + """Return all advertised room ids using one bounded database read.""" + + rooms = _query_room_ids(room_ids) + if not rooms: + return set() + now = float(clock()) + available: set[str] = set() + with _transaction(db_path) as conn: + for index in range(0, len(rooms), MAX_ROOM_IDS): + batch = rooms[index : index + MAX_ROOM_IDS] + placeholders = ",".join("?" for _ in batch) + rows = conn.execute( + f"""SELECT DISTINCT room_id FROM desktop_room_presence + WHERE room_id IN ({placeholders}) AND expires_at > ?""", + (*batch, now), + ).fetchall() + available.update(str(row["room_id"]) for row in rows) + return available + + +def latest_command_states( + db_path: Path | str, + room_ids: Any, +) -> dict[str, dict[str, Any]]: + """Return the newest command state for each requested room.""" + + rooms = _query_room_ids(room_ids) + if not rooms: + return {} + states: dict[str, dict[str, Any]] = {} + with _transaction(db_path) as conn: + for index in range(0, len(rooms), MAX_ROOM_IDS): + batch = rooms[index : index + MAX_ROOM_IDS] + placeholders = ",".join("?" for _ in batch) + rows = conn.execute( + f"""SELECT c.* FROM desktop_room_commands AS c + WHERE c.room_id IN ({placeholders}) + AND c.command_id = ( + SELECT newer.command_id + FROM desktop_room_commands AS newer + WHERE newer.room_id = c.room_id + ORDER BY newer.created_at DESC, newer.command_id DESC + LIMIT 1 + )""", + tuple(batch), + ).fetchall() + states.update({str(row["room_id"]): _command(row) for row in rows}) + return states diff --git a/gateway/group_chat_slash.py b/gateway/group_chat_slash.py new file mode 100644 index 0000000000000..208410a0bfa3b --- /dev/null +++ b/gateway/group_chat_slash.py @@ -0,0 +1,762 @@ +"""Messaging command surface for Bot Group Chats.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from pathlib import Path +from typing import Optional + +from gateway.platforms.base import MessageEvent + + +logger = logging.getLogger("gateway.run") + + +_GROUP_CHAT_RATE_WINDOW_SECONDS = 60.0 + + +_GROUP_CHAT_READ_RATE_LIMIT = 30 + + +_GROUP_CHAT_MUTATION_RATE_LIMIT = 12 + + +_GROUP_CHAT_STOP_RATE_LIMIT = 30 + + +_GROUP_CHAT_RATE_BUCKET_CAP = 2048 + + +_NATIVE_DISTINCT_DM_PLATFORMS = frozenset({ + "bluebubbles", + "dingtalk", + "email", + "feishu", + "mattermost", + "qqbot", + "sms", + "wecom", + "wecom_callback", + "weixin", + "whatsapp_cloud", + "yuanbao", +}) + + +class GroupChatSlashCommandsMixin: + """Authorize, render, and mutate Group Chats from messaging clients.""" + + def _home_chat_is_single_operator(self, event: MessageEvent) -> bool: + """Recognize the configured home chat's exact authorized operator.""" + from gateway.authz_mixin import ( + _auth_env, + _coerce_allow_set, + _platform_authorization_env_names, + ) + from gateway.slash_access import is_home_control_source + + source = event.source + if getattr(source, "delivered_via_upstream_relay", False) is True: + return False + if not is_home_control_source(self.config, source): + return False + + is_authorized = getattr(self, "_is_user_authorized_for_source", None) + if not callable(is_authorized): + return False + try: + if not is_authorized(source): + return False + except Exception: + return False + + def _census() -> bool: + platform_name = source.platform.value + allowed_users_env, allow_all_env = _platform_authorization_env_names( + source.platform + ) + candidates: set[str] = set() + adapter_for_source = getattr(self, "_adapter_for_source", None) + transport_adapter = ( + adapter_for_source(source) if callable(adapter_for_source) else None + ) + adapter_config = getattr(transport_adapter, "config", None) + adapter_extra = getattr(adapter_config, "extra", None) + if transport_adapter is not None: + extra = adapter_extra if isinstance(adapter_extra, dict) else {} + else: + platform_config = self.config.platforms.get(source.platform) + extra = getattr(platform_config, "extra", None) or {} + candidates.update(_coerce_allow_set(extra.get("allow_from"))) + candidates.update(_coerce_allow_set(_auth_env(allowed_users_env))) + candidates.update(_coerce_allow_set(_auth_env("GATEWAY_ALLOWED_USERS"))) + if _auth_env("GATEWAY_ALLOW_ALL_USERS").lower() in { + "true", + "1", + "yes", + }: + return False + if allow_all_env and _auth_env(allow_all_env).lower() in { + "true", + "1", + "yes", + }: + return False + + authorization_home = getattr( + source, + "_authorization_profile_home", + None, + ) + if authorization_home is not None: + pairing_store = getattr(self, "pairing_store", None) + else: + pairing_store_for = getattr(self, "_pairing_store_for", None) + pairing_store = ( + pairing_store_for(source) + if callable(pairing_store_for) + else None + ) + if pairing_store is not None: + try: + candidates.update( + str(row.get("user_id") or "").strip() + for row in pairing_store.list_approved(platform_name) + if str(row.get("user_id") or "").strip() + ) + except Exception: + return False + if not candidates or "*" in candidates: + return False + + user_id = str(source.user_id) + matcher = getattr(pairing_store, "_user_ids_match", None) + if callable(matcher): + return all( + matcher(platform_name, candidate, user_id) + for candidate in candidates + ) + return candidates == {user_id} + + authorization_home = getattr(source, "_authorization_profile_home", None) + if authorization_home is None: + return _census() + from gateway.run import _profile_runtime_scope + + with _profile_runtime_scope(Path(authorization_home)): + return _census() + + + def _can_control_group_chats(self, event: MessageEvent) -> bool: + """Authorize a trusted DM or the exact operator of an explicit home chat.""" + from gateway.slash_access import policy_for_source + + if self._home_chat_is_single_operator(event): + return True + # ``chat_type=dm`` is not a privacy boundary: Slack MPIMs and Matrix + # m.direct rooms can contain several people. Adapters stamp this + # transport-local signal only when they can prove the current surface is + # one-to-one. Unknown and older connectors fail closed. + platform = str(getattr(getattr(event.source, "platform", None), "value", "") or "") + native_distinct_dm = ( + getattr(event.source, "delivered_via_upstream_relay", False) is not True + and platform in _NATIVE_DISTINCT_DM_PLATFORMS + and str(getattr(event.source, "chat_type", "") or "").casefold() + in {"dm", "direct", "private"} + ) + if getattr(event.source, "is_one_to_one", None) is not True and not native_distinct_dm: + return False + chat_type = str(getattr(event.source, "chat_type", "") or "").casefold() + if chat_type not in {"", "dm", "direct", "private"}: + return False + policy = policy_for_source(self.config, event.source) + return policy.enabled and policy.is_admin(event.source.user_id) + + + @staticmethod + def _group_chat_control_denial(event: MessageEvent) -> str: + chat_type = str(getattr(event.source, "chat_type", "") or "").casefold() + platform = str(getattr(getattr(event.source, "platform", None), "value", "") or "") + proven_private = getattr(event.source, "is_one_to_one", None) is True or ( + getattr(event.source, "delivered_via_upstream_relay", False) is not True + and platform in _NATIVE_DISTINCT_DM_PLATFORMS + and chat_type in {"dm", "direct", "private"} + ) + if ( + chat_type not in {"", "dm", "direct", "private"} + or not proven_private + ): + return ( + "Group Chat controls are private. Use your authorized one-to-one " + "Hermes chat." + ) + return ( + "This chat can’t control Group Chats. Use your authorized one-to-one " + "Hermes chat or authorize this account in settings." + ) + + + def _group_chat_rate_limit_denial( + self, + event: MessageEvent, + *, + action: str, + ) -> Optional[str]: + """Bound authenticated Group Chat commands per person and chat.""" + + normalized_action = str(action or "read").casefold() + if normalized_action == "stop": + limit = _GROUP_CHAT_STOP_RATE_LIMIT + bucket_kind = "stop" + elif normalized_action in {"send", "retry"}: + limit = _GROUP_CHAT_MUTATION_RATE_LIMIT + bucket_kind = "change" + else: + limit = _GROUP_CHAT_READ_RATE_LIMIT + bucket_kind = "read" + + source = event.source + platform = str(getattr(getattr(source, "platform", None), "value", "") or "") + key = ( + platform, + str(getattr(source, "scope_id", None) or ""), + str(getattr(source, "chat_id", None) or ""), + str(getattr(source, "user_id_alt", None) or getattr(source, "user_id", None) or ""), + bucket_kind, + ) + now = time.monotonic() + buckets = getattr(self, "_group_chat_command_rate_buckets", None) + if not isinstance(buckets, dict): + buckets = {} + self._group_chat_command_rate_buckets = buckets + recent = [ + stamp + for stamp in buckets.get(key, ()) + if now - stamp < _GROUP_CHAT_RATE_WINDOW_SECONDS + ] + if len(recent) >= limit: + buckets[key] = recent + return "Too many Group Chat commands. Wait a moment and try again." + recent.append(now) + buckets[key] = recent + + if len(buckets) > _GROUP_CHAT_RATE_BUCKET_CAP: + stale_before = now - _GROUP_CHAT_RATE_WINDOW_SECONDS + for bucket_key in list(buckets): + if not buckets[bucket_key] or buckets[bucket_key][-1] <= stale_before: + buckets.pop(bucket_key, None) + while len(buckets) > _GROUP_CHAT_RATE_BUCKET_CAP: + buckets.pop(next(iter(buckets))) + return None + + + @staticmethod + def _group_chat_profile(event: MessageEvent) -> str: + """Return the profile selected by the authenticated inbound route.""" + + routed = str(getattr(event.source, "profile", None) or "").strip() + if routed: + return routed + from hermes_cli.profiles import get_active_profile_name + + return str(get_active_profile_name() or "default") + + + async def _handle_rooms_command(self, event: MessageEvent) -> Optional[str]: + """List Bot Group Chats or show one chat's recent activity.""" + + from gateway import hosted_rooms + from gateway.hosted_room_messaging import ( + RoomControlError, + current_room_backend, + format_room_bot_detail, + format_room_bot_list, + format_room_detail, + format_room_list, + is_message_edit, + is_machine_authored, + list_messaging_rooms, + messaging_event_id, + relay_provenance_is_unknown, + resolve_room, + resolve_room_picker_choice, + room_bot_picker_choices, + room_picker_choices, + ) + + if is_machine_authored(event): + return "Group Chat controls are only available to people." + if is_message_edit(event): + return "Edited messages can’t run Group Chat commands. Send a new message." + if relay_provenance_is_unknown(event): + return ( + "Group Chat controls need a relay connector that reports whether the " + "sender is a person or a bot. Update the connector and try again." + ) + if not self._can_control_group_chats(event): + return self._group_chat_control_denial(event) + service = current_room_backend() + rooms_command = f"{self._typed_command_prefix_for(event.source.platform)}group" + query = event.get_command_args().strip() + try: + words = query.split() + if ( + words + and words[0].isdecimal() + and len(words) > 1 + and words[1].casefold() in { + "approve", + "deny", + "retry", + "send", + "stop", + } + ): + return await self._handle_room_command(event) + denial = self._group_chat_rate_limit_denial(event, action="read") + if denial: + return denial + profile = self._group_chat_profile(event) + rooms = await asyncio.to_thread( + list_messaging_rooms, + service, + profile=profile, + ) + if ( + len(words) == 2 + and words[0].isdecimal() + and words[1].casefold() == "approvals" + ): + from gateway.hosted_room_messaging_approvals import ( + MessagingApprovalError, + approval_member_label, + approval_picker_choices, + format_approval_picker_title, + format_pending_approvals, + pending_approvals_for_room, + resolve_approval_picker_choice, + submit_room_approval, + ) + + room = resolve_room(rooms, words[0]) + pending = await asyncio.to_thread( + pending_approvals_for_room, + service, + room, + ) + if not pending: + return "This Group Chat has no pending approvals." + choices = approval_picker_choices(room, pending) + source = await asyncio.to_thread( + self._normalize_source_for_session_key, + event.source, + ) + session_key = self._session_key_for_source(source) + + async def _on_approval_selected(_chat_id: str, value: str) -> str: + if not self._can_control_group_chats(event): + return self._group_chat_control_denial(event) + current_denial = self._group_chat_rate_limit_denial( + event, + action="approve", + ) + if current_denial: + return current_denial + try: + current_rooms = await asyncio.to_thread( + list_messaging_rooms, + service, + profile=profile, + ) + current_room = resolve_room(current_rooms, words[0]) + current_pending = await asyncio.to_thread( + pending_approvals_for_room, + service, + current_room, + ) + index, choice, request_id = resolve_approval_picker_choice( + current_room, + current_pending, + value, + ) + _number, selected, applied = await asyncio.to_thread( + submit_room_approval, + service, + current_room, + command_id=( + f"approval:{messaging_event_id(event)}:" + f"{str(value).replace('=', '.')}" + ), + choice=choice, + selection=index, + expected_request_id=request_id, + ) + bot = approval_member_label( + current_room, + str(selected["member_id"]), + ) + if applied.get("applied") is False: + return str(applied.get("result") or "Approval expired.") + if applied.get("queued"): + return f"Decision sent for {bot}." + return ( + f"Approved once for {bot}." + if choice == "once" + else f"Denied for {bot}." + ) + except MessagingApprovalError as exc: + return str(exc) + except Exception: + logger.exception("Failed to apply Group Chat approval") + return "Couldn’t apply that approval. Check the Group Chat again." + + picker_sent = bool(choices) and await self._try_send_choice_picker( + event, + session_key, + title=format_approval_picker_title(room, pending), + choices=choices, + on_choice_selected=_on_approval_selected, + ) + if picker_sent: + return None + return format_pending_approvals( + service, + room, + room_reference=words[0], + room_command=rooms_command, + ) + if ( + len(words) >= 2 + and words[0].isdecimal() + and words[1].casefold() in {"bot", "bots"} + ): + room = resolve_room(rooms, words[0]) + if words[1].casefold() == "bot": + if len(words) != 3: + return f"Use `{rooms_command} {words[0]} bot `." + return await asyncio.to_thread( + format_room_bot_detail, + service, + room, + words[2], + room_command=rooms_command, + ) + if len(words) != 2: + return f"Use `{rooms_command} {words[0]} bots`." + choices = await asyncio.to_thread( + room_bot_picker_choices, + service, + room, + ) + source = await asyncio.to_thread( + self._normalize_source_for_session_key, + event.source, + ) + session_key = self._session_key_for_source(source) + + async def _on_bot_selected(_chat_id: str, value: str) -> str: + if not self._can_control_group_chats(event): + return self._group_chat_control_denial(event) + current_denial = self._group_chat_rate_limit_denial( + event, + action="read", + ) + if current_denial: + return current_denial + try: + current_rooms = await asyncio.to_thread( + list_messaging_rooms, + service, + profile=profile, + ) + current_room = resolve_room(current_rooms, words[0]) + return await asyncio.to_thread( + format_room_bot_detail, + service, + current_room, + value, + room_command=rooms_command, + ) + except (RoomControlError, hosted_rooms.HostedRoomError) as exc: + return str(exc) + except Exception: + logger.exception("Failed to open Group Chat Bot from messaging") + return ( + "Couldn’t load that Bot. " + f"Run `{rooms_command} {words[0]} bots` again." + ) + + picker_sent = await self._try_send_choice_picker( + event, + session_key, + title=( + "🤖 Bots\n" + "Choose a Bot to see its handle and available controls." + ), + choices=choices, + on_choice_selected=_on_bot_selected, + ) + if picker_sent: + return None + return await asyncio.to_thread( + format_room_bot_list, + service, + room, + room_command=rooms_command, + ) + if not query: + choices = await asyncio.to_thread( + room_picker_choices, + service, + rooms, + ) + source = await asyncio.to_thread( + self._normalize_source_for_session_key, + event.source, + ) + session_key = self._session_key_for_source(source) + + async def _on_room_selected(_chat_id: str, value: str) -> str: + if not self._can_control_group_chats(event): + return self._group_chat_control_denial(event) + current_denial = self._group_chat_rate_limit_denial( + event, + action="read", + ) + if current_denial: + return current_denial + try: + current_rooms = await asyncio.to_thread( + list_messaging_rooms, + service, + profile=profile, + ) + selected = resolve_room_picker_choice(current_rooms, value) + return await asyncio.to_thread( + format_room_detail, + service, + selected, + room_command=rooms_command, + ) + except (RoomControlError, hosted_rooms.HostedRoomError) as exc: + return str(exc) + except Exception: + logger.exception("Failed to open Group Chat from messaging picker") + return ( + "Couldn’t load that Group Chat. " + f"Run `{rooms_command}` again." + ) + + picker_sent = await self._try_send_choice_picker( + event, + session_key, + title=( + "👥 Group Chats\n" + "Choose a recent Group Chat to see its status, Bots, activity, and actions. " + f"All: {rooms_command} list" + ), + choices=choices, + on_choice_selected=_on_room_selected, + ) + if picker_sent: + return None + exact_name = next( + ( + room + for room in rooms + if str(room.get("name") or "").casefold() == query.casefold() + ), + None, + ) + if exact_name is not None: + return await asyncio.to_thread( + format_room_detail, + service, + exact_name, + room_command=rooms_command, + ) + list_parts = query.casefold().split() + if not query or (list_parts and list_parts[0] == "list"): + if len(list_parts) > 2 or (len(list_parts) == 2 and not list_parts[1].isdecimal()): + return f"Use `{rooms_command} list [page]`." + page = int(list_parts[1]) if len(list_parts) == 2 else 1 + return await asyncio.to_thread( + format_room_list, + service, + rooms=rooms, + rooms_command=rooms_command, + page=page, + ) + + def _detail() -> str: + room = resolve_room(rooms, query) + return format_room_detail( + service, + room, + room_command=rooms_command, + ) + + return await asyncio.to_thread(_detail) + except (RoomControlError, hosted_rooms.HostedRoomError) as exc: + return str(exc) + except Exception: + logger.exception("Failed to read Bot Group Chats from messaging") + return "Couldn’t load Group Chats. Try again in a moment." + + + async def _handle_room_command(self, event: MessageEvent) -> str: + """Send to or stop work in a Bot Group Chat.""" + + from gateway import hosted_rooms + from gateway.hosted_room_messaging import ( + RoomControlError, + current_room_backend, + parse_room_command, + resolve_room, + room_reference, + retry_room, + send_to_room, + stop_room, + is_message_edit, + is_machine_authored, + list_messaging_rooms, + messaging_event_id, + relay_provenance_is_unknown, + ) + if is_machine_authored(event): + return "Group Chat controls are only available to people." + if is_message_edit(event): + return "Edited messages can’t run Group Chat commands. Send a new message." + if relay_provenance_is_unknown(event): + return ( + "Group Chat controls need a relay connector that reports whether the " + "sender is a person or a bot. Update the connector and try again." + ) + if not self._can_control_group_chats(event): + return self._group_chat_control_denial(event) + service = current_room_backend() + rooms_command = f"{self._typed_command_prefix_for(event.source.platform)}group" + try: + command = parse_room_command( + event.get_command_args(), + command_root=rooms_command, + ) + denial = self._group_chat_rate_limit_denial( + event, + action=command.action, + ) + if denial: + return denial + if not command.room_query.isdecimal(): + if command.action == "send": + raise RoomControlError( + f"Use `{rooms_command} send `." + ) + raise RoomControlError( + f"Use `{rooms_command} stop`." + ) + + def _mutate() -> str: + rooms = list_messaging_rooms( + service, + profile=self._group_chat_profile(event), + ) + approval_command_id = f"approval:{messaging_event_id(event)}" + approval_receipt = None + if command.action in {"approve", "deny"}: + from gateway.hosted_room_messaging_approvals import ( + approval_command, + terminalize_unowned_approval_commands, + ) + + terminalize_unowned_approval_commands( + service.db_path, + local_gateway_id=hosted_rooms.local_authority_gateway_id(), + ) + approval_receipt = approval_command( + service.db_path, + command_id=approval_command_id, + ) + if approval_receipt is not None and approval_receipt["state"] == "completed": + return str( + approval_receipt.get("result_text") + or "Approval is no longer available." + ) + if approval_receipt is None: + room = resolve_room(rooms, command.room_query) + else: + room = next( + ( + candidate + for candidate in rooms + if str(candidate.get("room_id") or "") + == str(approval_receipt["room_id"]) + and str(candidate.get("authority_gateway_id") or "") + == str(approval_receipt["authority_gateway_id"]) + and int(candidate.get("authority_epoch") or 0) + == int(approval_receipt["authority_epoch"]) + ), + None, + ) + if room is None: + raise RoomControlError( + "That approval is no longer available. Check Group Chats again." + ) + if ( + room.get("_room_mode") == "desktop" + and str(room.get("room_id") or "").startswith("name:") + ): + raise RoomControlError( + "Open this older Group Chat once in the latest Hermes Desktop " + "before changing it from messaging." + ) + if command.action == "send": + result = send_to_room(service, room, event, command.message) + return f"{result} Check: `{rooms_command} {room_reference(room)}`." + if command.action == "retry": + result = retry_room(service, room, event) + return f"{result} Check: `{rooms_command} {room_reference(room)}`." + if command.action in {"approve", "deny"}: + from gateway.hosted_room_messaging_approvals import ( + MessagingApprovalError, + approval_member_label, + submit_room_approval, + ) + + try: + _index, pending, applied = submit_room_approval( + service, + room, + command_id=approval_command_id, + choice=("once" if command.action == "approve" else "deny"), + selection=command.message, + ) + except MessagingApprovalError as exc: + raise RoomControlError(str(exc)) from exc + bot = approval_member_label( + room, + str(pending["member_id"]), + ) + if applied.get("applied") is False: + result = str(applied.get("result") or "Approval expired.") + return ( + f"{result} Check: `{rooms_command} {room_reference(room)}`." + ) + if applied.get("queued"): + result = f"Decision sent for {bot}." + elif command.action == "approve": + result = f"Approved once for {bot}." + else: + result = f"Denied for {bot}." + return f"{result} Check: `{rooms_command} {room_reference(room)}`." + result = stop_room(service, room, event) + return f"{result} Check: `{rooms_command} {room_reference(room)}`." + + return await asyncio.to_thread(_mutate) + except (RoomControlError, hosted_rooms.HostedRoomError) as exc: + return str(exc) + except Exception: + logger.exception("Failed to control hosted Bot room from messaging") + return "Couldn’t update that Bot room. Try again in a moment." diff --git a/gateway/hosted_room_control_client.py b/gateway/hosted_room_control_client.py new file mode 100644 index 0000000000000..929db7ea82e54 --- /dev/null +++ b/gateway/hosted_room_control_client.py @@ -0,0 +1,170 @@ +"""Credential-safe client for a room authority's reciprocal control API.""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from gateway.hosted_room_controls import StoredPeerRoomControl +from hermes_cli.urllib_security import open_credentialed_url + + +MAX_CONTROL_RESPONSE_BYTES = 1024 * 1024 + + +class RoomControlClientError(RuntimeError): + def __init__( + self, + message: str, + *, + status_code: int | None = None, + retryable: bool = False, + user_message: str = "", + ) -> None: + super().__init__(message) + self.status_code = status_code + self.retryable = retryable + self.user_message = user_message + + +class RoomControlHTTPClient: + def __init__( + self, + link: StoredPeerRoomControl, + *, + timeout_seconds: float = 15.0, + ) -> None: + self.link = link + self.timeout_seconds = float(timeout_seconds) + + def _request( + self, + *, + method: str, + body: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + room_id = urllib.parse.quote(self.link.room_id, safe="") + request = urllib.request.Request( + f"{self.link.home_url.rstrip('/')}/v1/room-controls/{room_id}", + data=( + json.dumps(body, ensure_ascii=True, separators=(",", ":")).encode( + "utf-8" + ) + if body is not None + else None + ), + method=method, + headers={ + "Authorization": f"HermesRoomControl {self.link.control_token}", + "X-Hermes-Room-Member": self.link.member_id, + "Content-Type": "application/json", + "User-Agent": "Hermes-RoomControl/1.0", + }, + ) + try: + with open_credentialed_url( + request, + timeout=self.timeout_seconds, + ) as response: + raw = response.read(MAX_CONTROL_RESPONSE_BYTES + 1) + if len(raw) > MAX_CONTROL_RESPONSE_BYTES: + raise RoomControlClientError( + "Group Chat control response exceeded the size limit" + ) + except urllib.error.HTTPError as exc: + try: + detail = exc.read(500).decode("utf-8", "replace") + except Exception: + detail = "" + user_message = "" + try: + payload = json.loads(detail) + raw_error = payload.get("error") if isinstance(payload, dict) else None + if isinstance(raw_error, dict): + candidate = str(raw_error.get("message") or "").strip() + if candidate and len(candidate) <= 300: + user_message = candidate + except (TypeError, ValueError): + pass + raise RoomControlClientError( + f"Group Chat host rejected control with HTTP {exc.code}", + status_code=exc.code, + retryable=exc.code in {408, 425, 429} or exc.code >= 500, + user_message=user_message, + ) from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise RoomControlClientError( + "Group Chat host is unreachable", + retryable=True, + ) from exc + try: + payload = json.loads(raw.decode("utf-8", "replace")) + except (UnicodeError, ValueError) as exc: + raise RoomControlClientError( + "Group Chat host returned invalid control data" + ) from exc + if not isinstance(payload, dict): + raise RoomControlClientError( + "Group Chat host returned a non-object control response" + ) + return payload + + def summary(self) -> dict[str, Any]: + return self._request(method="GET") + + def revoke(self) -> None: + self._request(method="DELETE") + + def mutate( + self, + *, + action: str, + command_id: str, + text: str = "", + actor_display_name: str = "Messaging", + ) -> dict[str, Any]: + return self._request( + method="POST", + body={ + "action": action, + "command_id": command_id, + **({"text": text} if text else {}), + **( + {"actor_display_name": actor_display_name} + if actor_display_name + else {} + ), + }, + ) + + +def revoke_stored_peer_control( + db_path: Path | str, + *, + room_id: str, + member_id: str, +) -> int: + """Revoke the authority credential, then erase peer bearer material.""" + + from gateway import hosted_room_controls + + link = next( + ( + candidate + for candidate in hosted_room_controls.load_peer_control_links( + db_path, include_inactive=True + ).links + if candidate.room_id == room_id and candidate.member_id == member_id + ), + None, + ) + if link is not None and link.status == "active": + RoomControlHTTPClient(link).revoke() + return hosted_room_controls.delete_peer_control_links( + db_path, room_id=room_id, member_id=member_id + ) diff --git a/gateway/hosted_room_controls.py b/gateway/hosted_room_controls.py new file mode 100644 index 0000000000000..a8f2891182f14 --- /dev/null +++ b/gateway/hosted_room_controls.py @@ -0,0 +1,1325 @@ +"""Private credentials for reciprocal hosted Group Chat control. + +The room's home gateway stores only a SHA-256 commitment for each scoped +control credential. A participating peer stores the corresponding bearer +credential and validated home endpoint in the gateway-wide ``state.db``. +Credentials are deliberately absent from reprs, status mappings, and errors. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import math +import os +import re +import secrets +import sqlite3 +import time +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterator + +from gateway.hosted_room_peer import ( + HostedRoomPeerError, + gateway_room_grant_secret, + validate_room_link_url, +) + + +TOKEN_BYTES = 32 +MAX_TOKEN_CHARS = 512 +MAX_PEER_LINKS = 1024 +MAX_LOAD_LINKS = MAX_PEER_LINKS +MAX_ROOM_NAME_CHARS = 200 +MAX_ROOM_MEMBERS = 64 +MAX_CONTROL_COMMANDS = 4096 +ROOM_LIFETIME_EXPIRES_AT = 253_402_300_799.0 +_JOURNAL_MODE_LOCK_RETRIES = 5 + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") +_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]+$") +_PEER_STATUSES = frozenset({"active", "expired", "revoked", "quarantined"}) +_DUMMY_DIGEST = hashlib.sha256(b"invalid-hosted-room-control-token").digest() + + +def control_retry_attempt_id(command_id: Any, task_id: Any) -> str: + """Return one stable, bounded Retry identity across direct/worker paths.""" + + command = str(command_id or "") + if command.startswith("worker:"): + command = command[len("worker:") :] + material = f"{command}|{str(task_id or '')}".encode("utf-8") + return f"room-retry:{hashlib.sha256(material).hexdigest()}" + + +class HostedRoomControlError(ValueError): + """Raised when a reciprocal room-control record is invalid or conflicts.""" + + +class HostedRoomControlConflictError(HostedRoomControlError): + """Raised when immutable control-link identity changes unexpectedly.""" + + +@dataclass(frozen=True) +class IssuedRoomControlToken: + """One-time credential returned by a room's authority gateway.""" + + room_id: str + member_id: str + authority_gateway_id: str + authority_epoch: int + control_token: str = field(repr=False) + status: str + created_at: float + expires_at: float + + def as_status(self) -> dict[str, Any]: + return { + "room_id": self.room_id, + "member_id": self.member_id, + "authority_gateway_id": self.authority_gateway_id, + "authority_epoch": self.authority_epoch, + "status": self.status, + "created_at": self.created_at, + "expires_at": self.expires_at, + } + + +@dataclass(frozen=True) +class StoredPeerRoomControl: + """Private peer-side route back to a room's authority gateway.""" + + room_id: str + member_id: str + home_url: str + transport_security: str + authority_gateway_id: str + authority_epoch: int + room_name: str + member_count: int + control_token: str = field(repr=False) + status: str + created_at: float + updated_at: float + expires_at: float + revoked_at: float | None = None + + def as_status(self) -> dict[str, Any]: + return { + "room_id": self.room_id, + "member_id": self.member_id, + "home_url": self.home_url, + "transport_security": self.transport_security, + "authority_gateway_id": self.authority_gateway_id, + "authority_epoch": self.authority_epoch, + "room_name": self.room_name, + "member_count": self.member_count, + "status": self.status, + "created_at": self.created_at, + "updated_at": self.updated_at, + "expires_at": self.expires_at, + **({"revoked_at": self.revoked_at} if self.revoked_at is not None else {}), + } + + +@dataclass(frozen=True) +class PeerRoomControlSave: + link: StoredPeerRoomControl + idempotent: bool + + +@dataclass(frozen=True) +class PeerRoomControlLoad: + links: tuple[StoredPeerRoomControl, ...] + quarantined: int + truncated: bool + + +@dataclass(frozen=True) +class RoomControlCommandPlan: + task_ids: tuple[str, ...] + result: dict[str, Any] | None + idempotent: bool + + +@dataclass(frozen=True) +class PendingRoomControlRetry: + command_id: str + room_id: str + member_id: str + task_ids: tuple[str, ...] + + +def default_db_path() -> Path: + """Use the same gateway-wide state database as hosted room authority.""" + + from gateway.hosted_rooms import default_db_path as hosted_room_db_path + + return hosted_room_db_path() + + +def _identifier(value: Any, *, label: str) -> str: + if not isinstance(value, str): + raise HostedRoomControlError(f"invalid {label}") + normalized = value.strip() + if not _IDENTIFIER_RE.fullmatch(normalized): + raise HostedRoomControlError(f"invalid {label}") + return normalized + + +def _authority_epoch(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise HostedRoomControlError("invalid authority_epoch") + return value + + +def _timestamp(value: Any, *, label: str) -> float: + if isinstance(value, bool): + raise HostedRoomControlError(f"invalid {label}") + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise HostedRoomControlError(f"invalid {label}") from exc + if not math.isfinite(parsed) or parsed <= 0: + raise HostedRoomControlError(f"invalid {label}") + return parsed + + +def _room_name(value: Any) -> str: + normalized = re.sub(r"\s+", " ", str(value or "")).strip() + if not normalized or len(normalized) > MAX_ROOM_NAME_CHARS: + raise HostedRoomControlError("invalid room_name") + return normalized + + +def _member_count(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise HostedRoomControlError("invalid member_count") + if not 1 <= value <= MAX_ROOM_MEMBERS: + raise HostedRoomControlError("invalid member_count") + return value + + +def _normalize_home_url(value: Any) -> tuple[str, str]: + try: + return validate_room_link_url(value) + except HostedRoomPeerError as exc: + raise HostedRoomControlError("invalid home control endpoint") from exc + + +def _token_is_strong(value: Any) -> bool: + if ( + not isinstance(value, str) + or not value + or len(value) > MAX_TOKEN_CHARS + or not _TOKEN_RE.fullmatch(value) + ): + return False + try: + padding = "=" * (-len(value) % 4) + material = base64.urlsafe_b64decode(value + padding) + except (ValueError, TypeError): + return False + return len(material) >= TOKEN_BYTES + + +def _control_token(value: Any) -> str: + if not _token_is_strong(value): + raise HostedRoomControlError("invalid control credential") + return value + + +def _derived_control_token( + *, + room_id: str, + member_id: str, + authority_gateway_id: str, + authority_epoch: int, + request_id: str, +) -> str: + material = "\0".join( + ( + "hermes-room-control-v1", + room_id, + member_id, + authority_gateway_id, + str(authority_epoch), + request_id, + ) + ).encode("utf-8") + digest = hmac.new(gateway_room_grant_secret(), material, hashlib.sha256).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def _secure_db_file(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + descriptor = os.open(path, os.O_CREAT | os.O_RDWR, 0o600) + os.close(descriptor) + if os.name == "posix": + try: + path.chmod(0o600) + except OSError: + pass + + +def _initialize_schema(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_control_tokens ( + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + request_id TEXT NOT NULL, + token_hash BLOB NOT NULL CHECK (length(token_hash) = 32), + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + expires_at REAL NOT NULL, + revoked_at REAL, + PRIMARY KEY ( + room_id, member_id, authority_gateway_id, authority_epoch + ) + )""" + ) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_hosted_room_control_tokens_room + ON hosted_room_control_tokens(room_id, status, expires_at)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_peer_controls ( + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + room_name TEXT NOT NULL, + member_count INTEGER NOT NULL CHECK (member_count BETWEEN 1 AND 64), + home_url TEXT NOT NULL, + transport_security TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + control_token TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('active', 'expired', 'revoked', 'quarantined') + ), + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + expires_at REAL NOT NULL, + revoked_at REAL, + quarantine_reason TEXT, + PRIMARY KEY (room_id, member_id) + )""" + ) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_hosted_room_peer_controls_status + ON hosted_room_peer_controls(status, expires_at, updated_at)""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_control_commands ( + command_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + action TEXT NOT NULL, + task_ids_json TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + result_json TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + + +def _schema_is_current(conn: sqlite3.Connection) -> bool: + home = { + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_control_tokens)") + } + peer = { + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_peer_controls)") + } + commands = { + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_control_commands)") + } + return { + "room_id", + "member_id", + "authority_gateway_id", + "authority_epoch", + "request_id", + "token_hash", + "status", + "created_at", + "updated_at", + "expires_at", + "revoked_at", + }.issubset(home) and { + "room_id", + "member_id", + "room_name", + "member_count", + "home_url", + "transport_security", + "authority_gateway_id", + "authority_epoch", + "control_token", + "status", + "created_at", + "updated_at", + "expires_at", + "revoked_at", + "quarantine_reason", + }.issubset(peer) and { + "command_id", + "room_id", + "member_id", + "action", + "task_ids_json", + "state", + "result_json", + "created_at", + "updated_at", + }.issubset(commands) + + +def _migrate_schema(conn: sqlite3.Connection) -> None: + home = { + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_control_tokens)") + } + if home and "request_id" not in home: + conn.execute( + """ALTER TABLE hosted_room_control_tokens + ADD COLUMN request_id TEXT NOT NULL DEFAULT 'legacy'""" + ) + + +def _connect(db_path: Path | str) -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + + path = Path(db_path) + _secure_db_file(path) + conn = sqlite3.connect(path, timeout=10) + conn.row_factory = sqlite3.Row + try: + for attempt in range(_JOURNAL_MODE_LOCK_RETRIES): + try: + apply_wal_with_fallback( + conn, db_label="state.db (hosted room controls)" + ) + break + except sqlite3.OperationalError as exc: + if ( + str(exc).lower() != "database is locked" + or attempt + 1 == _JOURNAL_MODE_LOCK_RETRIES + ): + raise + time.sleep(0.01 * (2**attempt)) + conn.execute("PRAGMA secure_delete=ON") + if not _schema_is_current(conn): + conn.execute("BEGIN IMMEDIATE") + _migrate_schema(conn) + _initialize_schema(conn) + if not _schema_is_current(conn): + raise HostedRoomControlError( + "hosted room control schema is incompatible" + ) + conn.commit() + except Exception: + conn.rollback() + conn.close() + raise + _secure_db_file(path) + return conn + + +@contextmanager +def _transaction( + db_path: Path | str, *, immediate: bool = False +) -> Iterator[sqlite3.Connection]: + conn = _connect(db_path) + try: + if immediate: + conn.execute("BEGIN IMMEDIATE") + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _active_room_scope( + conn: sqlite3.Connection, + *, + room_id: str, + authority_gateway_id: str, + authority_epoch: int, + member_id: str | None = None, +) -> bool: + table = conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_rooms'""" + ).fetchone() + if table is None: + return False + row = conn.execute( + """SELECT members_json FROM hosted_rooms + WHERE room_id=? AND authority_gateway_id=? + AND authority_epoch=? AND disbanded_at IS NULL""", + (room_id, authority_gateway_id, authority_epoch), + ).fetchone() + if row is None: + return False + if member_id is None: + return True + try: + members = json.loads(str(row["members_json"])) + except Exception: + return False + return any( + isinstance(member, dict) + and str(member.get("member_id") or member.get("profile") or "") == member_id + for member in members + ) + + +def issue_home_control_token( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + authority_gateway_id: Any, + authority_epoch: Any, + expires_at: Any, + request_id: Any | None = None, + now: float | None = None, +) -> IssuedRoomControlToken: + """Create one replay-safe member credential and retain only its hash.""" + + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + authority_gateway_id = _identifier( + authority_gateway_id, label="authority_gateway_id" + ) + authority_epoch = _authority_epoch(authority_epoch) + normalized_request_id = ( + _identifier(request_id, label="request_id") + if request_id is not None + else f"one-shot:{secrets.token_hex(16)}" + ) + created_at = _timestamp(time.time() if now is None else now, label="now") + expires_at = _timestamp(expires_at, label="expires_at") + if expires_at <= created_at: + raise HostedRoomControlError("control credential expiry must be in the future") + + control_token = ( + _derived_control_token( + room_id=room_id, + member_id=member_id, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + request_id=normalized_request_id, + ) + if request_id is not None + else secrets.token_urlsafe(TOKEN_BYTES) + ) + token_hash = hashlib.sha256(control_token.encode("ascii")).digest() + with _transaction(db_path, immediate=True) as conn: + if not _active_room_scope( + conn, + room_id=room_id, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + member_id=member_id, + ): + raise HostedRoomControlError( + "active Group Chat authority scope is unavailable" + ) + existing = conn.execute( + """SELECT request_id, status, expires_at + FROM hosted_room_control_tokens + WHERE room_id=? AND member_id=? AND authority_gateway_id=? + AND authority_epoch=?""", + (room_id, member_id, authority_gateway_id, authority_epoch), + ).fetchone() + if existing is not None and existing["status"] == "active": + if ( + str(existing["request_id"]) == normalized_request_id + and float(existing["expires_at"]) == expires_at + ): + return IssuedRoomControlToken( + room_id=room_id, + member_id=member_id, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + control_token=control_token, + status="active", + created_at=created_at, + expires_at=expires_at, + ) + if ( + str(existing["request_id"]) != "legacy" + and float(existing["expires_at"]) > created_at + ): + raise HostedRoomControlConflictError( + "an active control credential already exists for this scope" + ) + conn.execute( + """INSERT INTO hosted_room_control_tokens( + room_id, member_id, authority_gateway_id, authority_epoch, + request_id, token_hash, status, created_at, updated_at, expires_at, + revoked_at + ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, NULL) + ON CONFLICT( + room_id, member_id, authority_gateway_id, authority_epoch + ) DO UPDATE SET + token_hash=excluded.token_hash, + request_id=excluded.request_id, + status='active', + created_at=excluded.created_at, + updated_at=excluded.updated_at, + expires_at=excluded.expires_at, + revoked_at=NULL""", + ( + room_id, + member_id, + authority_gateway_id, + authority_epoch, + normalized_request_id, + token_hash, + created_at, + created_at, + expires_at, + ), + ) + return IssuedRoomControlToken( + room_id=room_id, + member_id=member_id, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + control_token=control_token, + status="active", + created_at=created_at, + expires_at=expires_at, + ) + + +def verify_home_control_token( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + authority_gateway_id: Any, + authority_epoch: Any, + control_token: Any, + now: float | None = None, +) -> bool: + """Verify one exact live room/member/authority scope in constant time.""" + + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + authority_gateway_id = _identifier( + authority_gateway_id, label="authority_gateway_id" + ) + authority_epoch = _authority_epoch(authority_epoch) + timestamp = _timestamp(time.time() if now is None else now, label="now") + token_shape_valid = _token_is_strong(control_token) + token_material = ( + control_token.encode("ascii") if token_shape_valid else b"invalid-credential" + ) + candidate_hash = hashlib.sha256(token_material).digest() + + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT token_hash, status, expires_at + FROM hosted_room_control_tokens + WHERE room_id=? AND member_id=? AND authority_gateway_id=? + AND authority_epoch=?""", + (room_id, member_id, authority_gateway_id, authority_epoch), + ).fetchone() + room_active = _active_room_scope( + conn, + room_id=room_id, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + member_id=member_id, + ) + + stored_hash: bytes = _DUMMY_DIGEST + eligible = False + if row is not None: + raw_hash = row["token_hash"] + if isinstance(raw_hash, bytes) and len(raw_hash) == 32: + stored_hash = raw_hash + try: + stored_expiry = float(row["expires_at"]) + except (TypeError, ValueError): + stored_expiry = float("nan") + eligible = bool( + row["status"] == "active" + and math.isfinite(stored_expiry) + and stored_expiry > timestamp + and room_active + and token_shape_valid + ) + digest_matches = hmac.compare_digest(stored_hash, candidate_hash) + return bool(eligible and digest_matches) + + +def revoke_home_control_tokens( + db_path: Path | str, + *, + room_id: Any, + member_id: Any | None = None, + authority_gateway_id: Any | None = None, + authority_epoch: Any | None = None, + now: float | None = None, +) -> int: + """Idempotently revoke matching home-side credential commitments.""" + + clauses = ["room_id=?", "status='active'"] + params: list[Any] = [_identifier(room_id, label="room_id")] + if member_id is not None: + clauses.append("member_id=?") + params.append(_identifier(member_id, label="member_id")) + if authority_gateway_id is not None: + clauses.append("authority_gateway_id=?") + params.append(_identifier(authority_gateway_id, label="authority_gateway_id")) + if authority_epoch is not None: + clauses.append("authority_epoch=?") + params.append(_authority_epoch(authority_epoch)) + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + cursor = conn.execute( + f"""UPDATE hosted_room_control_tokens + SET status='revoked', updated_at=?, revoked_at=? + WHERE {" AND ".join(clauses)}""", + (timestamp, timestamp, *params), + ) + return cursor.rowcount + + +def revoke_home_control_token_value( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + control_token: Any, + now: float | None = None, +) -> int: + """Idempotently revoke the exact bearer, including response-lost retries.""" + + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + token_hash = hashlib.sha256( + _control_token(control_token).encode("ascii") + ).digest() + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + rows = conn.execute( + """SELECT token_hash, status FROM hosted_room_control_tokens + WHERE room_id=? AND member_id=?""", + (room_id, member_id), + ).fetchall() + matched = next( + ( + row + for row in rows + if hmac.compare_digest(bytes(row["token_hash"]), token_hash) + ), + None, + ) + if matched is None: + raise HostedRoomControlError("control credential is invalid") + if str(matched["status"]) == "revoked": + return 0 + changed = conn.execute( + """UPDATE hosted_room_control_tokens + SET status='revoked', updated_at=?, revoked_at=? + WHERE room_id=? AND member_id=? AND token_hash=? + AND status='active'""", + (timestamp, timestamp, room_id, member_id, token_hash), + ) + if changed.rowcount not in {0, 1}: + raise HostedRoomControlError("control credential changed more than once") + return changed.rowcount + + +def _peer_link_from_row(row: sqlite3.Row) -> StoredPeerRoomControl: + room_id = _identifier(row["room_id"], label="room_id") + member_id = _identifier(row["member_id"], label="member_id") + authority_gateway_id = _identifier( + row["authority_gateway_id"], label="authority_gateway_id" + ) + authority_epoch = _authority_epoch(row["authority_epoch"]) + room_name = _room_name(row["room_name"]) + member_count = _member_count(row["member_count"]) + home_url, transport_security = _normalize_home_url(row["home_url"]) + if row["transport_security"] != transport_security: + raise HostedRoomControlError( + "stored control endpoint classification is invalid" + ) + status = str(row["status"] or "") + if status not in _PEER_STATUSES: + raise HostedRoomControlError("stored control status is invalid") + created_at = _timestamp(row["created_at"], label="created_at") + updated_at = _timestamp(row["updated_at"], label="updated_at") + expires_at = _timestamp(row["expires_at"], label="expires_at") + revoked_at = row["revoked_at"] + if revoked_at is not None: + revoked_at = _timestamp(revoked_at, label="revoked_at") + control_token = _control_token(row["control_token"]) + return StoredPeerRoomControl( + room_id=room_id, + member_id=member_id, + home_url=home_url, + transport_security=transport_security, + authority_gateway_id=authority_gateway_id, + authority_epoch=authority_epoch, + room_name=room_name, + member_count=member_count, + control_token=control_token, + status=status, + created_at=created_at, + updated_at=updated_at, + expires_at=expires_at, + revoked_at=revoked_at, + ) + + +def save_peer_control_link( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + home_url: Any, + authority_gateway_id: Any, + authority_epoch: Any, + room_name: Any, + member_count: Any, + control_token: Any, + expires_at: Any, + allow_rotation: bool = False, + now: float | None = None, +) -> PeerRoomControlSave: + """Persist a private peer link, rejecting any immutable identity drift.""" + + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + home_url, transport_security = _normalize_home_url(home_url) + authority_gateway_id = _identifier( + authority_gateway_id, label="authority_gateway_id" + ) + authority_epoch = _authority_epoch(authority_epoch) + room_name = _room_name(room_name) + member_count = _member_count(member_count) + control_token = _control_token(control_token) + timestamp = _timestamp(time.time() if now is None else now, label="now") + expires_at = _timestamp(expires_at, label="expires_at") + if expires_at <= timestamp: + raise HostedRoomControlError("control link expiry must be in the future") + + with _transaction(db_path, immediate=True) as conn: + existing = conn.execute( + """SELECT * FROM hosted_room_peer_controls + WHERE room_id=? AND member_id=?""", + (room_id, member_id), + ).fetchone() + if existing is not None and str(existing["status"]) in {"expired", "revoked"}: + conn.execute( + "DELETE FROM hosted_room_peer_controls WHERE room_id=? AND member_id=?", + (room_id, member_id), + ) + existing = None + if existing is not None: + try: + stored = _peer_link_from_row(existing) + except Exception as exc: + conn.execute( + """UPDATE hosted_room_peer_controls + SET status='quarantined', quarantine_reason='invalid_stored_link', + updated_at=? + WHERE room_id=? AND member_id=?""", + (timestamp, room_id, member_id), + ) + raise HostedRoomControlConflictError( + "stored control link is quarantined" + ) from exc + same_token = hmac.compare_digest(stored.control_token, control_token) + if not ( + stored.home_url == home_url + and stored.authority_gateway_id == authority_gateway_id + and stored.authority_epoch == authority_epoch + ): + raise HostedRoomControlConflictError( + "control link conflicts with stored authority" + ) + rotating = not same_token or stored.expires_at != expires_at + if rotating and allow_rotation is not True: + raise HostedRoomControlConflictError( + "control link conflicts with stored authority" + ) + if ( + rotating + or stored.room_name != room_name + or stored.member_count != member_count + ): + conn.execute( + """UPDATE hosted_room_peer_controls + SET room_name=?, member_count=?, control_token=?, + expires_at=?, status='active', revoked_at=NULL, + quarantine_reason=NULL, updated_at=? + WHERE room_id=? AND member_id=?""", + ( + room_name, + member_count, + control_token, + expires_at, + timestamp, + room_id, + member_id, + ), + ) + stored = _peer_link_from_row( + conn.execute( + """SELECT * FROM hosted_room_peer_controls + WHERE room_id=? AND member_id=?""", + (room_id, member_id), + ).fetchone() + ) + return PeerRoomControlSave(link=stored, idempotent=not rotating) + + count = conn.execute( + "SELECT COUNT(*) FROM hosted_room_peer_controls WHERE status='active'" + ).fetchone()[0] + if int(count) >= MAX_PEER_LINKS: + raise HostedRoomControlError("stored control link limit reached") + conn.execute( + """INSERT INTO hosted_room_peer_controls( + room_id, member_id, room_name, member_count, + home_url, transport_security, + authority_gateway_id, authority_epoch, control_token, + status, created_at, updated_at, expires_at, revoked_at, + quarantine_reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, NULL, NULL)""", + ( + room_id, + member_id, + room_name, + member_count, + home_url, + transport_security, + authority_gateway_id, + authority_epoch, + control_token, + timestamp, + timestamp, + expires_at, + ), + ) + stored = _peer_link_from_row( + conn.execute( + """SELECT * FROM hosted_room_peer_controls + WHERE room_id=? AND member_id=?""", + (room_id, member_id), + ).fetchone() + ) + return PeerRoomControlSave(link=stored, idempotent=False) + + +def load_peer_control_links( + db_path: Path | str, + *, + limit: int = MAX_LOAD_LINKS, + include_inactive: bool = False, + now: float | None = None, +) -> PeerRoomControlLoad: + """Load a bounded page and quarantine malformed private records.""" + + if ( + isinstance(limit, bool) + or not isinstance(limit, int) + or not 1 <= limit <= MAX_LOAD_LINKS + ): + raise HostedRoomControlError(f"limit must be between 1 and {MAX_LOAD_LINKS}") + timestamp = _timestamp(time.time() if now is None else now, label="now") + links: list[StoredPeerRoomControl] = [] + quarantined = 0 + with _transaction(db_path, immediate=True) as conn: + rows = conn.execute( + """SELECT rowid, * FROM hosted_room_peer_controls + ORDER BY updated_at DESC, room_id ASC, member_id ASC + LIMIT ?""", + (limit + 1,), + ).fetchall() + truncated = len(rows) > limit + for row in rows[:limit]: + if row["status"] == "quarantined": + continue + try: + link = _peer_link_from_row(row) + except Exception: + conn.execute( + """UPDATE hosted_room_peer_controls + SET status='quarantined', quarantine_reason='invalid_stored_link', + updated_at=? WHERE rowid=?""", + (timestamp, row["rowid"]), + ) + quarantined += 1 + continue + if link.status == "active" and link.expires_at <= timestamp: + conn.execute( + """UPDATE hosted_room_peer_controls + SET status='expired', updated_at=? WHERE rowid=?""", + (timestamp, row["rowid"]), + ) + link = StoredPeerRoomControl(**{ + **link.__dict__, + "status": "expired", + "updated_at": timestamp, + }) + if include_inactive or link.status == "active": + links.append(link) + return PeerRoomControlLoad( + links=tuple(links), quarantined=quarantined, truncated=truncated + ) + + +def revoke_peer_control_links( + db_path: Path | str, + *, + room_id: Any, + member_id: Any | None = None, + now: float | None = None, +) -> int: + """Idempotently revoke one peer link or every link for a room.""" + + clauses = ["room_id=?", "status NOT IN ('revoked', 'quarantined')"] + params: list[Any] = [_identifier(room_id, label="room_id")] + if member_id is not None: + clauses.append("member_id=?") + params.append(_identifier(member_id, label="member_id")) + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + cursor = conn.execute( + f"""UPDATE hosted_room_peer_controls + SET status='revoked', updated_at=?, revoked_at=? + WHERE {" AND ".join(clauses)}""", + (timestamp, timestamp, *params), + ) + return cursor.rowcount + + +def delete_peer_control_links( + db_path: Path | str, + *, + room_id: Any, + member_id: Any | None = None, +) -> int: + """Erase peer-side bearer material after reciprocal revocation.""" + + clauses = ["room_id=?"] + params: list[Any] = [_identifier(room_id, label="room_id")] + if member_id is not None: + clauses.append("member_id=?") + params.append(_identifier(member_id, label="member_id")) + with _transaction(db_path, immediate=True) as conn: + cursor = conn.execute( + f"DELETE FROM hosted_room_peer_controls WHERE {' AND '.join(clauses)}", + params, + ) + return cursor.rowcount + + +def update_peer_control_metadata( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + authority_gateway_id: Any, + authority_epoch: Any, + room_name: Any, + member_count: Any, + now: float | None = None, +) -> bool: + """Refresh presentation-only metadata after an authenticated home read.""" + + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + changed = conn.execute( + """UPDATE hosted_room_peer_controls + SET room_name=?, member_count=?, updated_at=? + WHERE room_id=? AND member_id=? AND authority_gateway_id=? + AND authority_epoch=? AND status='active'""", + ( + _room_name(room_name), + _member_count(member_count), + timestamp, + _identifier(room_id, label="room_id"), + _identifier(member_id, label="member_id"), + _identifier(authority_gateway_id, label="authority_gateway_id"), + _authority_epoch(authority_epoch), + ), + ) + if changed.rowcount not in {0, 1}: + raise HostedRoomControlError("control metadata changed more than once") + return changed.rowcount == 1 + + +def peer_reservation_matches( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + target_profile: Any, + authority_gateway_id: Any, + authority_epoch: Any, + now: float | None = None, +) -> bool: + """Bind a reciprocal link to the live target-side RoomLink reservation.""" + + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + target_profile = _identifier(target_profile, label="target_profile") + authority_gateway_id = _identifier( + authority_gateway_id, label="authority_gateway_id" + ) + authority_epoch = _authority_epoch(authority_epoch) + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path) as conn: + table = conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_room_peer_reservations'""" + ).fetchone() + if table is None: + return False + row = conn.execute( + """SELECT 1 FROM hosted_room_peer_reservations + WHERE room_id=? AND member_id=? AND target_profile=? + AND authority_gateway_id=? AND authority_epoch=? + AND expires_at>? AND revoked_at IS NULL""", + ( + room_id, + member_id, + target_profile, + authority_gateway_id, + authority_epoch, + timestamp, + ), + ).fetchone() + return row is not None + + +def begin_control_retry( + db_path: Path | str, + *, + command_id: Any, + room_id: Any, + member_id: Any, + task_ids: Any, + now: float | None = None, +) -> RoomControlCommandPlan: + """Freeze one remote retry delivery to one bounded task set.""" + + command_id = _identifier(command_id, label="command_id") + room_id = _identifier(room_id, label="room_id") + member_id = _identifier(member_id, label="member_id") + if not isinstance(task_ids, (list, tuple)) or len(task_ids) > 8: + raise HostedRoomControlError("retry task_ids must contain at most 8 tasks") + frozen = tuple(_identifier(value, label="task_id") for value in task_ids) + if len(set(frozen)) != len(frozen): + raise HostedRoomControlError("retry task_ids must be unique") + encoded = json.dumps(frozen, ensure_ascii=True, separators=(",", ":")) + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + if conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_rooms'""" + ).fetchone(): + conn.execute( + """DELETE FROM hosted_room_control_commands + WHERE room_id IN ( + SELECT room_id FROM hosted_rooms + WHERE disbanded_at IS NOT NULL + )""" + ) + existing = conn.execute( + "SELECT * FROM hosted_room_control_commands WHERE command_id=?", + (command_id,), + ).fetchone() + if existing is not None: + stored_tasks = tuple( + _identifier(value, label="task_id") + for value in json.loads(str(existing["task_ids_json"])) + ) + if ( + str(existing["room_id"]) != room_id + or str(existing["member_id"]) != member_id + or str(existing["action"]) != "retry" + or (frozen and stored_tasks != frozen) + ): + raise HostedRoomControlConflictError( + "control command conflicts with its durable retry plan" + ) + result = ( + json.loads(str(existing["result_json"])) + if existing["state"] == "completed" and existing["result_json"] + else None + ) + return RoomControlCommandPlan( + task_ids=stored_tasks, + result=result, + idempotent=True, + ) + count = conn.execute( + "SELECT COUNT(*) FROM hosted_room_control_commands" + ).fetchone()[0] + if int(count) >= MAX_CONTROL_COMMANDS: + raise HostedRoomControlError("stored control command limit reached") + if not frozen: + raise HostedRoomControlError("retry task_ids must contain 1-8 tasks") + conn.execute( + """INSERT INTO hosted_room_control_commands ( + command_id, room_id, member_id, action, task_ids_json, + state, result_json, created_at, updated_at + ) VALUES (?, ?, ?, 'retry', ?, 'pending', NULL, ?, ?)""", + (command_id, room_id, member_id, encoded, timestamp, timestamp), + ) + return RoomControlCommandPlan(task_ids=frozen, result=None, idempotent=False) + + +def load_pending_control_retries( + db_path: Path | str, + *, + room_id: Any, + limit: int = 8, +) -> tuple[PendingRoomControlRetry, ...]: + """Load a bounded retry queue for the process that owns the room lease.""" + + room_id = _identifier(room_id, label="room_id") + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64: + raise HostedRoomControlError("pending retry limit must be between 1 and 64") + pending: list[PendingRoomControlRetry] = [] + timestamp = time.time() + invalid_result = json.dumps( + {"action": "retry", "error": "invalid_stored_plan", "retried": 0}, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ) + with _transaction(db_path, immediate=True) as conn: + rows = conn.execute( + """SELECT command_id, room_id, member_id, task_ids_json + FROM hosted_room_control_commands + WHERE room_id=? AND action='retry' AND state='pending' + ORDER BY updated_at, created_at, command_id + LIMIT ?""", + (room_id, limit), + ).fetchall() + for row in rows: + try: + raw_task_ids = json.loads(str(row["task_ids_json"])) + if not isinstance(raw_task_ids, list): + raise HostedRoomControlError("stored retry task_ids are invalid") + task_ids = tuple( + _identifier(value, label="task_id") + for value in raw_task_ids + ) + if ( + not task_ids + or len(task_ids) > 8 + or len(set(task_ids)) != len(task_ids) + ): + raise HostedRoomControlError("stored retry task_ids are invalid") + pending.append( + PendingRoomControlRetry( + command_id=_identifier( + row["command_id"], label="command_id" + ), + room_id=_identifier(row["room_id"], label="room_id"), + member_id=_identifier( + row["member_id"], label="member_id" + ), + task_ids=task_ids, + ) + ) + except Exception: + conn.execute( + """UPDATE hosted_room_control_commands + SET state='completed', result_json=?, updated_at=? + WHERE command_id=? AND state='pending'""", + (invalid_result, timestamp, row["command_id"]), + ) + return tuple(pending) + + +def defer_control_retry( + db_path: Path | str, + *, + command_id: Any, + now: float | None = None, +) -> bool: + """Rotate a still-pending command behind newer work after a retry failure.""" + + command_id = _identifier(command_id, label="command_id") + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + changed = conn.execute( + """UPDATE hosted_room_control_commands + SET updated_at=? + WHERE command_id=? AND action='retry' AND state='pending'""", + (timestamp, command_id), + ) + return changed.rowcount == 1 + + +def complete_control_retry( + db_path: Path | str, + *, + command_id: Any, + result: Mapping[str, Any], + lease: Any | None = None, + now: float | None = None, +) -> dict[str, Any]: + """Commit one remote retry result idempotently.""" + + command_id = _identifier(command_id, label="command_id") + encoded = json.dumps( + dict(result), ensure_ascii=True, sort_keys=True, separators=(",", ":") + ) + if len(encoded.encode("utf-8")) > 16 * 1024: + raise HostedRoomControlError("control command result is too large") + timestamp = _timestamp(time.time() if now is None else now, label="now") + with _transaction(db_path, immediate=True) as conn: + if lease is not None: + from gateway import hosted_room_driver + + hosted_room_driver.require_active_lease_in_transaction( + conn, + lease, + now=timestamp, + ) + row = conn.execute( + "SELECT state, result_json FROM hosted_room_control_commands WHERE command_id=?", + (command_id,), + ).fetchone() + if row is None: + raise HostedRoomControlError("control retry plan is missing") + if row["state"] == "completed": + if str(row["result_json"] or "") != encoded: + raise HostedRoomControlConflictError( + "control retry result changed after completion" + ) + return json.loads(encoded) + changed = conn.execute( + """UPDATE hosted_room_control_commands + SET state='completed', result_json=?, updated_at=? + WHERE command_id=? AND state='pending'""", + (encoded, timestamp, command_id), + ) + if changed.rowcount != 1: + raise HostedRoomControlConflictError( + "control retry completion raced another result" + ) + return json.loads(encoded) diff --git a/gateway/hosted_room_driver.py b/gateway/hosted_room_driver.py index e2f836ca5185a..06681dc51f805 100644 --- a/gateway/hosted_room_driver.py +++ b/gateway/hosted_room_driver.py @@ -350,6 +350,22 @@ def _create_task_table( ) +def _initialize_retry_receipt_table(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_retry_receipts ( + retry_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + task_id TEXT NOT NULL, + source_execution_generation INTEGER NOT NULL + CHECK (source_execution_generation >= 0), + created_at REAL NOT NULL, + FOREIGN KEY (room_id, task_id) + REFERENCES hosted_room_driver_tasks(room_id, task_id) + ON DELETE CASCADE + )""" + ) + + def _initialize_schema(conn: sqlite3.Connection) -> None: conn.execute( """CREATE TABLE IF NOT EXISTS hosted_room_driver_leases ( @@ -366,6 +382,7 @@ def _initialize_schema(conn: sqlite3.Connection) -> None: )""" ) _create_task_table(conn) + _initialize_retry_receipt_table(conn) _validate_schema(conn) conn.execute( """CREATE INDEX IF NOT EXISTS idx_hosted_room_driver_tasks_status @@ -423,8 +440,37 @@ def _task_schema_supports_current_statuses(conn: sqlite3.Connection) -> bool: return "'stopping'" in sql and "'deferred'" in sql +def _task_schema_has_legacy_retry_id(conn: sqlite3.Connection) -> bool: + return any( + row[1] == "retry_id" + for row in conn.execute("PRAGMA table_info(hosted_room_driver_tasks)") + ) + + def _migrate_task_status_constraint(conn: sqlite3.Connection) -> None: """Expand the unpublished task-state CHECK without losing durable work.""" + preserved_receipts = [] + receipt_table = conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_room_retry_receipts'""" + ).fetchone() + if receipt_table is not None: + preserved_receipts.extend( + conn.execute( + """SELECT retry_id, room_id, task_id, + source_execution_generation, created_at + FROM hosted_room_retry_receipts""" + ).fetchall() + ) + if _task_schema_has_legacy_retry_id(conn): + preserved_receipts.extend( + conn.execute( + """SELECT retry_id, room_id, task_id, execution_generation, updated_at + FROM hosted_room_driver_tasks + WHERE retry_id IS NOT NULL AND retry_id != ''""" + ).fetchall() + ) + conn.execute("DROP TABLE IF EXISTS hosted_room_retry_receipts") conn.execute("DROP INDEX IF EXISTS idx_hosted_room_driver_tasks_status") _create_task_table(conn, "hosted_room_driver_tasks_next") columns = ", ".join(_TASK_COLUMN_ORDER) @@ -442,6 +488,36 @@ def _migrate_task_status_constraint(conn: sqlite3.Connection) -> None: room_id, status, source_event_seq, created_at, task_id )""" ) + _initialize_retry_receipt_table(conn) + for receipt in preserved_receipts: + existing = conn.execute( + "SELECT room_id, task_id FROM hosted_room_retry_receipts WHERE retry_id=?", + (str(receipt["retry_id"]),), + ).fetchone() + if existing is not None and ( + str(existing["room_id"]), str(existing["task_id"]) + ) != (str(receipt["room_id"]), str(receipt["task_id"])): + raise DriverStateError("draft retry_id is bound to multiple tasks") + conn.execute( + """INSERT OR IGNORE INTO hosted_room_retry_receipts( + retry_id, room_id, task_id, source_execution_generation, created_at + ) VALUES (?, ?, ?, ?, ?)""", + ( + str(receipt["retry_id"]), + str(receipt["room_id"]), + str(receipt["task_id"]), + int( + receipt["source_execution_generation"] + if "source_execution_generation" in receipt.keys() + else receipt["execution_generation"] + ), + float( + receipt["created_at"] + if "created_at" in receipt.keys() + else receipt["updated_at"] + ), + ), + ) def _connect(db_path: Path | str) -> sqlite3.Connection: @@ -455,10 +531,15 @@ def _connect(db_path: Path | str) -> sqlite3.Connection: apply_wal_with_fallback(conn, db_label="state.db (hosted_room_driver)") conn.execute("PRAGMA foreign_keys=ON") if _schema_objects_exist(conn): - if not _task_schema_supports_current_statuses(conn): + if ( + _task_schema_has_legacy_retry_id(conn) + or not _task_schema_supports_current_statuses(conn) + ): conn.execute("BEGIN IMMEDIATE") _migrate_task_status_constraint(conn) conn.commit() + _initialize_retry_receipt_table(conn) + conn.commit() _validate_schema(conn) return conn # Schema creation is one database-wide transaction. The driver schema @@ -488,6 +569,61 @@ def _transaction(db_path: Path | str) -> Iterator[sqlite3.Connection]: conn.close() +def _record_retry_receipt( + conn: sqlite3.Connection, + *, + retry_id: str | None, + row: sqlite3.Row, + now: float, +) -> None: + if retry_id is None: + return + retry_id = _identifier(retry_id, label="retry_id") + existing = conn.execute( + """SELECT room_id, task_id FROM hosted_room_retry_receipts + WHERE retry_id=?""", + (retry_id,), + ).fetchone() + if existing is not None: + if (str(existing["room_id"]), str(existing["task_id"])) != ( + str(row["room_id"]), + str(row["task_id"]), + ): + raise TaskConflictError("retry_id is already bound to another task") + return + conn.execute( + """INSERT INTO hosted_room_retry_receipts( + retry_id, room_id, task_id, source_execution_generation, created_at + ) VALUES (?, ?, ?, ?, ?)""", + ( + retry_id, + str(row["room_id"]), + str(row["task_id"]), + int(row["execution_generation"]), + now, + ), + ) + + +def retry_receipt_exists( + db_path: Path | str, + *, + room_id: Any, + task_id: Any, + retry_id: Any, +) -> bool: + room_id = _identifier(room_id, label="room_id") + task_id = _identifier(task_id, label="task_id") + retry_id = _identifier(retry_id, label="retry_id") + with _transaction(db_path) as conn: + row = conn.execute( + """SELECT 1 FROM hosted_room_retry_receipts + WHERE retry_id=? AND room_id=? AND task_id=?""", + (retry_id, room_id, task_id), + ).fetchone() + return row is not None + + def _lease_from_row( row: sqlite3.Row | dict[str, Any], *, reclaimed: bool = False ) -> DriverLease: @@ -635,6 +771,63 @@ def _require_active_lease( return row +def require_active_lease_in_transaction( + conn: sqlite3.Connection, + lease: DriverLease, + *, + now: Any, +) -> DriverLease: + """Validate an exact lease inside a caller-owned SQLite transaction.""" + + timestamp = _timestamp(lambda: now) + return _lease_from_row(_require_active_lease(conn, lease, now=timestamp)) + + +def _cancel_task_behind_stop_fence( + conn: sqlite3.Connection, + row: sqlite3.Row, + *, + now: float, +) -> sqlite3.Row | None: + stop = conn.execute( + """SELECT seq FROM hosted_room_events + WHERE room_id=? AND kind='room.stop_requested' + ORDER BY seq DESC LIMIT 1""", + (row["room_id"],), + ).fetchone() + if stop is None or int(row["source_event_seq"]) >= int(stop["seq"]): + return None + cancel_id = f"stop-fence:{int(stop['seq'])}" + changed = conn.execute( + """UPDATE hosted_room_driver_tasks + SET status='cancelled', cancel_generation=cancel_generation + 1, + cancel_id=?, terminal_at=?, updated_at=? + WHERE room_id=? AND task_id=? AND status=? + AND execution_generation=? AND cancel_generation=?""", + ( + cancel_id, + now, + now, + row["room_id"], + row["task_id"], + row["status"], + int(row["execution_generation"]), + int(row["cancel_generation"]), + ), + ) + if changed.rowcount != 1: + raise StaleTaskError("task changed while applying the room stop fence") + return _load_task( + conn, + TaskIdentity( + room_id=str(row["room_id"]), + task_id=str(row["task_id"]), + thread_id=str(row["thread_id"]), + turn_id=str(row["turn_id"]), + ), + ) + + def acquire_lease( db_path: Path | str, *, @@ -754,6 +947,20 @@ def acquire_lease( return _lease_from_row(current, reclaimed=True) +def require_active_lease( + db_path: Path | str, + lease: DriverLease, + *, + clock: Clock, +) -> DriverLease: + """Revalidate one exact lease generation without extending its lifetime.""" + + now = _timestamp(clock) + with _transaction(db_path) as conn: + current = _require_active_lease(conn, lease, now=now) + return _lease_from_row(current) + + def renew_lease( db_path: Path | str, lease: DriverLease, @@ -915,7 +1122,7 @@ def start_task( *, expected_cancel_generation: int, clock: Clock, -) -> TaskAttempt: +) -> TaskAttempt | None: """Move one queued task to running under the current driver lease.""" if lease.room_id != identity.room_id: raise DriverValidationError("lease and task belong to different rooms") @@ -934,6 +1141,8 @@ def start_task( raise InvalidTaskTransitionError( f"cannot start task in state '{row['status']}'" ) + if _cancel_task_behind_stop_fence(conn, row, now=now) is not None: + return None unresolved = conn.execute( """SELECT task_id, status FROM hosted_room_driver_tasks WHERE room_id=? AND status IN ('running', 'indeterminate', 'stopping') @@ -1118,6 +1327,7 @@ def resolve_indeterminate_task( status: TerminalStatus, result: Any, clock: Clock, + retry_id: Any = None, ) -> dict[str, Any]: """Commit a verified historical receipt under the current room lease.""" if lease.room_id != identity.room_id: @@ -1135,6 +1345,7 @@ def resolve_indeterminate_task( ): raise DriverValidationError("expected_cancel_generation must be non-negative") settlement_id = _identifier(settlement_id, label="settlement_id") + retry_id = _identifier(retry_id, label="retry_id") if retry_id is not None else None if status not in {"settled", "failed"}: raise DriverValidationError("status must be 'settled' or 'failed'") result_json = _canonical_json(result) @@ -1149,6 +1360,7 @@ def resolve_indeterminate_task( and row["settlement_status"] == status and row["result_json"] == result_json ): + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(row, idempotent=True) raise TaskConflictError("task already has a different terminal settlement") if ( @@ -1178,6 +1390,7 @@ def resolve_indeterminate_task( ) if updated.rowcount != 1: raise StaleTaskError("indeterminate task changed during reconciliation") + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(_load_task(conn, identity)) @@ -1190,6 +1403,7 @@ def resolve_indeterminate_cancellation( expected_cancel_generation: int, cancel_id: Any, clock: Clock, + retry_id: Any = None, ) -> dict[str, Any]: """Commit a verified terminal cancellation for an uncertain attempt.""" if lease.room_id != identity.room_id: @@ -1207,11 +1421,13 @@ def resolve_indeterminate_cancellation( ): raise DriverValidationError("expected_cancel_generation must be non-negative") cancel_id = _identifier(cancel_id, label="cancel_id") + retry_id = _identifier(retry_id, label="retry_id") if retry_id is not None else None now = _timestamp(clock) with _transaction(db_path) as conn: _require_active_lease(conn, lease, now=now) row = _load_task(conn, identity) if row["status"] == "cancelled" and row["cancel_id"] == cancel_id: + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(row, idempotent=True) if ( row["status"] != "indeterminate" @@ -1238,6 +1454,7 @@ def resolve_indeterminate_cancellation( ) if updated.rowcount != 1: raise StaleTaskError("indeterminate cancellation proof lost its fence") + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(_load_task(conn, identity)) @@ -1249,6 +1466,7 @@ def requeue_indeterminate_task( expected_execution_generation: int, expected_cancel_generation: int, clock: Clock, + retry_id: Any = None, ) -> dict[str, Any]: """Explicitly retry uncertain work after an operator accepts at-least-once risk.""" if lease.room_id != identity.room_id: @@ -1265,6 +1483,7 @@ def requeue_indeterminate_task( or expected_cancel_generation < 0 ): raise DriverValidationError("expected_cancel_generation must be non-negative") + retry_id = _identifier(retry_id, label="retry_id") if retry_id is not None else None now = _timestamp(clock) with _transaction(db_path) as conn: _require_active_lease(conn, lease, now=now) @@ -1275,6 +1494,10 @@ def requeue_indeterminate_task( or int(row["cancel_generation"]) != expected_cancel_generation ): raise StaleTaskError("indeterminate task generation changed") + stopped = _cancel_task_behind_stop_fence(conn, row, now=now) + if stopped is not None: + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) + return _task_from_row(stopped) updated = conn.execute( """UPDATE hosted_room_driver_tasks SET status='queued', run_gateway_id=NULL, @@ -1292,6 +1515,7 @@ def requeue_indeterminate_task( ) if updated.rowcount != 1: raise StaleTaskError("indeterminate task changed during requeue") + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(_load_task(conn, identity)) @@ -1368,6 +1592,7 @@ def requeue_deferred_task( expected_execution_generation: int, expected_cancel_generation: int, clock: Clock, + retry_id: Any = None, ) -> dict[str, Any]: """Explicitly retry a fenced deferred turn under a new generation.""" @@ -1385,6 +1610,7 @@ def requeue_deferred_task( or expected_cancel_generation < 0 ): raise DriverValidationError("expected_cancel_generation must be non-negative") + retry_id = _identifier(retry_id, label="retry_id") if retry_id is not None else None now = _timestamp(clock) with _transaction(db_path) as conn: _require_active_lease(conn, lease, now=now) @@ -1395,6 +1621,10 @@ def requeue_deferred_task( or int(row["cancel_generation"]) != expected_cancel_generation ): raise StaleTaskError("deferred task generation changed") + stopped = _cancel_task_behind_stop_fence(conn, row, now=now) + if stopped is not None: + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) + return _task_from_row(stopped) updated = conn.execute( """UPDATE hosted_room_driver_tasks SET status='queued', run_gateway_id=NULL, @@ -1413,6 +1643,7 @@ def requeue_deferred_task( ) if updated.rowcount != 1: raise StaleTaskError("deferred task changed during requeue") + _record_retry_receipt(conn, retry_id=retry_id, row=row, now=now) return _task_from_row(_load_task(conn, identity)) @@ -1559,7 +1790,7 @@ def begin_task_cancel( SET status='stopping', cancel_generation=?, cancel_id=?, updated_at=? WHERE room_id=? AND task_id=? - AND status IN ('running', 'indeterminate') + AND status IN ('running', 'indeterminate', 'deferred') AND cancel_generation=?""", ( expected_cancel_generation + 1, diff --git a/gateway/hosted_room_messaging.py b/gateway/hosted_room_messaging.py new file mode 100644 index 0000000000000..f52f33c6d30bf --- /dev/null +++ b/gateway/hosted_room_messaging.py @@ -0,0 +1,1989 @@ +"""Messaging-facing controls for gateway-hosted Bot rooms. + +The handlers in :mod:`gateway.slash_commands` deliberately stay thin. This +module owns parsing, room lookup, bounded presentation, and server-owned actor +identity so Telegram, Signal, WhatsApp, and the other gateway transports share +one behavior contract. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import sqlite3 +import time +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from gateway import hosted_room_discussion as discussion +from gateway import hosted_room_driver as driver +from gateway import hosted_room_controls, hosted_room_links +from gateway import hosted_rooms +from gateway.hosted_room_control_client import ( + RoomControlClientError, + RoomControlHTTPClient, +) + + +MAX_ROOM_CHOICES = 8 +MAX_RECENT_MESSAGES = 5 +MAX_PREVIEW_CHARS = 180 +MAX_GROUP_MEMBERS = 6 +MAX_MESSAGING_ROOMS = 4096 +GROUP_CHAT_SYNC_META_KEY = "hermes-bots-groups" + + +def _projected_desktop_rooms(*, profile: str = "default") -> list[dict[str, Any]]: + """Read the bounded classic-room projection shared by Desktop clients.""" + + try: + import yaml + from hermes_cli.profiles import get_profile_dir + + profile_meta = Path(get_profile_dir(profile)) / "profile.yaml" + if not profile_meta.is_file(): + return [] + raw = yaml.safe_load(profile_meta.read_text(encoding="utf-8")) or {} + ui_meta = raw.get("ui_meta") if isinstance(raw, Mapping) else None + snapshot = ( + ui_meta.get(GROUP_CHAT_SYNC_META_KEY) + if isinstance(ui_meta, Mapping) + else None + ) + raw_rooms = snapshot.get("rooms") if isinstance(snapshot, Mapping) else None + except Exception: + return [] + if not isinstance(raw_rooms, Mapping): + return [] + try: + snapshot_version = int(snapshot.get("version") or 0) + except (TypeError, ValueError): + snapshot_version = 0 + + rooms: list[dict[str, Any]] = [] + for key, raw_room in raw_rooms.items(): + if not isinstance(raw_room, Mapping): + continue + hosted = raw_room.get("hosted") + if isinstance(hosted, str) and hosted.strip(): + continue + name = _clean_line(raw_room.get("name") or key, limit=200) + explicit_room_id = str(raw_room.get("roomId") or "").strip() + room_id = ( + explicit_room_id + or (str(key).strip() if snapshot_version >= 3 else f"name:{name}") + ) + if ( + not name + or not room_id + or len(room_id) > 200 + or any(char in room_id for char in ("\x00", "\r", "\n")) + ): + continue + authority_hash = str(raw_room.get("desktopAuthorityHash") or "").strip().lower() + if not re.fullmatch(r"[a-f0-9]{64}", authority_hash): + authority_hash = "" + raw_log = raw_room.get("log") + log = [dict(item) for item in raw_log if isinstance(item, Mapping)] if isinstance(raw_log, list) else [] + raw_members = raw_room.get("members") + members = ( + [dict(item) for item in raw_members if isinstance(item, Mapping)] + if isinstance(raw_members, list) + else [] + ) + updated_at = max( + (float(item.get("at") or 0) for item in log), + default=float(snapshot.get("updatedAt") or 0) / 1000, + ) + rooms.append( + { + "room_id": room_id, + "name": name, + "members": members, + "log": log, + "created_at": min( + (float(item.get("at") or 0) for item in log), + default=updated_at, + ), + "updated_at": updated_at, + "desktop_authority_hash": authority_hash, + "_room_mode": "desktop", + } + ) + return rooms + + +def _room_profiles(room: Mapping[str, Any]) -> set[str]: + profiles: set[str] = set() + members = room.get("members") + if not isinstance(members, list): + return profiles + for member in members: + if not isinstance(member, Mapping): + continue + profile = str( + member.get("target_profile") + or member.get("profile") + or member.get("member_id") + or "" + ).strip() + if profile: + profiles.add(profile) + target = member.get("target") + if isinstance(target, Mapping): + target_profile = str( + target.get("target_profile") or target.get("profile") or "" + ).strip() + if target_profile: + profiles.add(target_profile) + return profiles + + +def list_messaging_rooms( + service: Any, + *, + profile: str = "default", +) -> list[dict[str, Any]]: + """Return hosted and reachable classic rooms with stable short numbers.""" + + hosted: list[dict[str, Any]] = [] + offset = 0 + while offset < MAX_MESSAGING_ROOMS: + page = hosted_rooms.list_rooms( + service.db_path, + limit=hosted_rooms.MAX_ROOM_LIST_LIMIT, + offset=offset, + ) + hosted.extend({**room, "_room_mode": "hosted"} for room in page) + if len(page) < hosted_rooms.MAX_ROOM_LIST_LIMIT: + break + offset += len(page) + if len(hosted) >= MAX_MESSAGING_ROOMS: + raise RoomControlError( + "There are too many Group Chats to list safely. Disband inactive chats and try again." + ) + # The default profile is the installation owner's master chat. Secondary + # profiles see only rooms whose frozen roster includes that profile. + if profile != "default": + hosted = [room for room in hosted if profile in _room_profiles(room)] + hosted_ids = {str(room["room_id"]) for room in hosted} + remote: list[dict[str, Any]] = [] + remote_ids: set[str] = set() + try: + peer_links = hosted_room_controls.load_peer_control_links( + service.db_path, + limit=hosted_room_controls.MAX_LOAD_LINKS, + ).links + except hosted_room_controls.HostedRoomControlError: + peer_links = () + for link in peer_links: + if link.room_id in hosted_ids or link.room_id in remote_ids: + continue + if profile != "default" and not hosted_room_controls.peer_reservation_matches( + service.db_path, + room_id=link.room_id, + member_id=link.member_id, + target_profile=profile, + authority_gateway_id=link.authority_gateway_id, + authority_epoch=link.authority_epoch, + ): + continue + remote_ids.add(link.room_id) + remote.append( + { + "room_id": link.room_id, + "name": link.room_name, + "members": [], + "member_count": link.member_count, + "authority_gateway_id": link.authority_gateway_id, + "authority_epoch": link.authority_epoch, + "created_at": link.created_at, + "updated_at": link.updated_at, + "_remote_member_id": link.member_id, + "_room_mode": "remote", + } + ) + desktop = [ + room + for room in _projected_desktop_rooms(profile=profile) + if str(room["room_id"]) not in hosted_ids | remote_ids + ] + rooms = hosted + remote + desktop + if not rooms: + return [] + if len(rooms) > MAX_MESSAGING_ROOMS: + raise RoomControlError( + "There are too many Group Chats to list safely. Disband " + "inactive chats and try again." + ) + + from gateway.desktop_room_mailbox import ( + MAX_ROOM_IDS, + available_room_ids, + default_db_path, + failed_command_counts, + latest_command_states, + register_projected_authorities, + ) + + mailbox_db = default_db_path() + desktop_ids = [room["room_id"] for room in desktop] + commitments = [ + { + "room_id": room["room_id"], + "authority_hash": room["desktop_authority_hash"], + } + for room in desktop + if room.get("desktop_authority_hash") + ] + for index in range(0, len(commitments), MAX_ROOM_IDS): + register_projected_authorities( + mailbox_db, + commitments[index : index + MAX_ROOM_IDS], + ) + available = available_room_ids(mailbox_db, desktop_ids) + command_states = latest_command_states(mailbox_db, desktop_ids) + failed_counts = failed_command_counts(mailbox_db, desktop_ids) + rooms = [ + { + **room, + **( + { + "desktop_available": str(room["room_id"]) in available, + "desktop_command": command_states.get(str(room["room_id"])), + "desktop_failed_commands": failed_counts.get( + str(room["room_id"]), 0 + ), + } + if room.get("_room_mode") == "desktop" + else {} + ), + } + for room in rooms + ] + + # Keep the numeric reference ledger where #96274 first created it. Moving + # it to the compatibility mailbox would silently renumber existing group + # chats after upgrade, making `/group 2 send ...` target the wrong chat. + db_path = Path(service.db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path, timeout=10) + conn.row_factory = sqlite3.Row + try: + from hermes_state import apply_wal_with_fallback + + apply_wal_with_fallback(conn, db_label="state.db (room messaging refs)") + conn.execute("BEGIN IMMEDIATE") + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_messaging_refs ( + room_ref INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL UNIQUE + )""" + ) + # Existing rooms receive deterministic first-use numbers. AUTOINCREMENT + # keeps those numbers stable and prevents a disbanded room's reference + # from being reassigned to unrelated work later. + for room in sorted( + rooms, + key=lambda item: ( + float(item.get("created_at") or 0), + str(item.get("room_id") or ""), + ), + ): + conn.execute( + "INSERT OR IGNORE INTO hosted_room_messaging_refs (room_id) VALUES (?)", + (str(room["room_id"]),), + ) + refs = { + str(row["room_id"]): int(row["room_ref"]) + for row in conn.execute( + "SELECT room_id, room_ref FROM hosted_room_messaging_refs" + ) + } + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + return [ + {**room, "messaging_ref": refs[str(room["room_id"])]} + for room in rooms + ] + + +def room_reference(room: Mapping[str, Any]) -> str: + """Return the short messaging reference, with an internal-id fallback.""" + + reference = room.get("messaging_ref") + if isinstance(reference, int) and reference > 0: + return str(reference) + return str(room.get("room_id") or "") + + +def _frozen_desktop_recipients(room: Mapping[str, Any]) -> list[dict[str, Any]]: + """Return the bounded routing identity visible in the trusted projection.""" + + raw_members = room.get("members") + if not isinstance(raw_members, list): + return [] + recipients: list[dict[str, Any]] = [] + for raw in raw_members[:MAX_GROUP_MEMBERS]: + if not isinstance(raw, Mapping): + continue + name = str(raw.get("name") or "").strip()[:128] + if not name: + continue + recipient: dict[str, Any] = {"name": name} + for key, limit in ( + ("handle", 128), + ("connectionId", 128), + ("connectionKind", 64), + ("connectionLabel", 128), + ): + value = str(raw.get(key) or "").strip() + if value: + recipient[key] = value[:limit] + if raw.get("sourceScoped") is True: + recipient["sourceScoped"] = True + recipients.append(recipient) + if len(recipients) < 2: + raise RoomControlError( + "This Group Chat’s Bot list is incomplete. Open it in Hermes Desktop and try again." + ) + return recipients + + +def _latest_projected_thread(room: Mapping[str, Any]) -> str: + raw_log = room.get("log") + if not isinstance(raw_log, list): + return "" + for entry in reversed(raw_log): + if not isinstance(entry, Mapping): + continue + thread = str(entry.get("thread") or "").strip() + if thread: + return thread[:128] + return "" + + +class RoomControlError(ValueError): + """A user-actionable hosted-room command error.""" + + +def _room_member_count(room: Mapping[str, Any]) -> int: + value = room.get("member_count") + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return value + members = room.get("members") + return len(members) if isinstance(members, list) else 0 + + +def _room_status_icon(status: str) -> str: + lowered = str(status or "").casefold() + if any( + word in lowered + for word in ("attention", "blocked", "error", "offline", "unavailable") + ): + return "⚠️" + if "connected" in lowered: + return "⚪" + if any( + word in lowered + for word in ("queued", "running", "stopping", "waiting", "working") + ): + return "🟡" + return "🟢" + + +def _picker_display_label(value: Any, *, limit: int) -> str: + """Neutralize rich markup and notification-shaped @mentions in pickers.""" + + return _plain_display_label(value, limit=limit) + + +def room_picker_choices( + service: Any, + rooms: list[Mapping[str, Any]], +) -> list[dict[str, Any]]: + """Build one bounded native-picker page without exposing room internals.""" + + choices: list[dict[str, Any]] = [] + for room in sorted( + rooms, + key=lambda item: ( + -float(item.get("updated_at") or item.get("created_at") or 0), + int(item.get("messaging_ref") or 0), + ), + )[:MAX_ROOM_CHOICES]: + reference = room_reference(room) + name = _clean_line(room.get("name") or room.get("room_id"), limit=42) + status = _room_status(service, room) + count = _room_member_count(room) + choices.append( + { + "value": _room_picker_value(room), + "label": ( + f"{_room_status_icon(status)} {reference}. " + f"{_picker_display_label(name, limit=42)} ({count})" + ), + "full_width": True, + "is_current": False, + } + ) + return choices + + +def _room_picker_value(room: Mapping[str, Any]) -> str: + seed = ":".join( + ( + str(room.get("_room_mode") or "hosted"), + str(room.get("connection_id") or room.get("_connection_id") or ""), + str(room.get("room_id") or ""), + ) + ) + return f"room-{hashlib.sha256(seed.encode()).hexdigest()[:16]}" + + +def resolve_room_picker_choice( + rooms: list[Mapping[str, Any]], + value: str, +) -> Mapping[str, Any]: + """Resolve an exact native-picker room token without number reuse.""" + + selected = [room for room in rooms if _room_picker_value(room) == str(value)] + if len(selected) != 1: + raise RoomControlError("This Group Chat is no longer available. Run the command again.") + return selected[0] + + +def _room_participant_lines(room: Mapping[str, Any]) -> list[str]: + raw_members = room.get("members") + if not isinstance(raw_members, list): + return [] + lines: list[str] = [] + for raw in raw_members[:MAX_GROUP_MEMBERS]: + if not isinstance(raw, Mapping): + continue + name = _room_member_name(raw) + handle = _room_member_handle(raw) + suffix = f" (`@{handle}`)" if handle else "" + lines.append(f"• {_plain_display_label(name)}{suffix}") + return lines + + +def _room_member_name(member: Mapping[str, Any]) -> str: + return _clean_line( + member.get("display_name") + or member.get("displayName") + or member.get("name") + or member.get("handle") + or "Bot", + limit=48, + ) + + +def _room_member_handle(member: Mapping[str, Any]) -> str: + handle = _clean_line( + member.get("handle"), + limit=driver.MAX_IDENTIFIER_CHARS, + ).lstrip("@") + return handle if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]*", handle) else "" + + +def _room_member_picker_value( + room: Mapping[str, Any], + member: Mapping[str, Any], +) -> str: + seed = json.dumps( + [ + str(room.get("room_id") or ""), + str(member.get("member_id") or ""), + _room_member_handle(member), + _room_member_name(member), + ], + ensure_ascii=False, + separators=(",", ":"), + ) + return f"p={hashlib.sha256(seed.encode()).hexdigest()[:16]}" + + +def _room_display_members(service: Any, room: Mapping[str, Any]) -> list[Mapping[str, Any]]: + if room.get("_room_mode") == "remote": + summary = _remote_summary(service, room) + remote_room = summary.get("room") + raw_members = ( + remote_room.get("members") + if isinstance(remote_room, Mapping) + else None + ) + else: + raw_members = room.get("members") + return [ + member + for member in (raw_members if isinstance(raw_members, list) else []) + if isinstance(member, Mapping) + ][:MAX_GROUP_MEMBERS] + + +def _room_with_messaging_reference( + service: Any, + room: Mapping[str, Any], +) -> Mapping[str, Any]: + if isinstance(room.get("messaging_ref"), int): + return room + room_id = str(room.get("room_id") or "") + return next( + ( + candidate + for candidate in list_messaging_rooms(service) + if str(candidate.get("room_id") or "") == room_id + ), + room, + ) + + +def room_bot_picker_choices( + service: Any, + room: Mapping[str, Any], +) -> list[dict[str, Any]]: + """Build a native participant picker without exposing profile internals.""" + + room = _room_with_messaging_reference(service, room) + choices: list[dict[str, Any]] = [] + for member in _room_display_members(service, room): + name = _room_member_name(member) + handle = _room_member_handle(member) + choices.append( + { + "value": _room_member_picker_value(room, member), + "label": f"🤖 {_picker_display_label(name, limit=48)}" + + (f" · {handle}" if handle else ""), + "full_width": True, + "is_current": False, + } + ) + return choices + + +def format_room_bot_list( + service: Any, + room: Mapping[str, Any], + *, + room_command: str = "/group", +) -> str: + """Render a bounded participant list with one stable in-roster number.""" + + room = _room_with_messaging_reference(service, room) + members = _room_display_members(service, room) + name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + reference = room_reference(room) + lines = [f"🤖 **Bots in {_plain_display_label(name, limit=72)}**"] + for index, member in enumerate(members, start=1): + member_name = _room_member_name(member) + handle = _room_member_handle(member) + lines.append( + f"{index}. **{_plain_display_label(member_name)}**" + + (f" · `@{handle}`" if handle else "") + ) + if not members: + lines.append("No Bots are available in this Group Chat.") + lines.extend( + [ + "", + "────────", + "🧭 **Controls**", + f"Bot details: `{room_command} {reference} bot `", + f"Back to Group Chat: `{room_command} {reference}`", + ] + ) + return "\n".join(lines) + + +def format_room_bot_detail( + service: Any, + room: Mapping[str, Any], + bot_query: str, + *, + room_command: str = "/group", +) -> str: + """Show one participant and only the controls the room contract supports.""" + + room = _room_with_messaging_reference(service, room) + members = _room_display_members(service, room) + raw_query = str(bot_query or "").strip() + normalized = raw_query.lstrip("@").casefold() + selected: Mapping[str, Any] | None = None + if raw_query.startswith("p="): + token_matches = [ + member + for member in members + if _room_member_picker_value(room, member) == raw_query + ] + if len(token_matches) == 1: + selected = token_matches[0] + elif not raw_query.startswith("@") and normalized.isdecimal(): + index = int(normalized) + if 1 <= index <= len(members): + selected = members[index - 1] + else: + matches = [ + member + for member in members + if normalized + in { + _room_member_handle(member).casefold(), + _room_member_name(member).casefold(), + _room_member_picker_value(room, member).casefold(), + } + ] + if len(matches) == 1: + selected = matches[0] + if selected is None: + raise RoomControlError("No Bot in this Group Chat matches that number or handle.") + + room_name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + reference = room_reference(room) + member_name = _room_member_name(selected) + handle = _room_member_handle(selected) + lines = [ + f"🤖 **{_plain_display_label(member_name)}**", + f"Group Chat: {_plain_display_label(room_name, limit=72)}", + ] + if handle: + lines.append(f"Handle: `@{handle}`") + lines.extend(["", "────────", "🧭 **Controls**"]) + if handle: + lines.append( + f"Message this Bot: `{room_command} {reference} send @{handle} `" + ) + lines.extend( + [ + f"All Bots: `{room_command} {reference} bots`", + f"Back to Group Chat: `{room_command} {reference}`", + ] + ) + return "\n".join(lines) + + +def _room_has_targetable_handle(room: Mapping[str, Any]) -> bool: + raw_members = room.get("members") + if not isinstance(raw_members, list): + return False + return any( + isinstance(raw, Mapping) + and bool( + re.sub( + r"[^A-Za-z0-9_.-]", + "", + _clean_line(raw.get("handle"), limit=48).lstrip("@"), + ) + ) + for raw in raw_members[:MAX_GROUP_MEMBERS] + ) + + +def _remote_control_link(service: Any, room: Mapping[str, Any]): + room_id = str(room.get("room_id") or "") + member_id = str(room.get("_remote_member_id") or "") + try: + links = hosted_room_controls.load_peer_control_links( + service.db_path, + limit=hosted_room_controls.MAX_LOAD_LINKS, + ).links + except hosted_room_controls.HostedRoomControlError as exc: + raise RoomControlError( + "This Group Chat connection needs repair. Open it in Hermes Desktop " + "on a connected device." + ) from exc + link = next( + ( + candidate + for candidate in links + if candidate.room_id == room_id and candidate.member_id == member_id + ), + None, + ) + if link is None: + raise RoomControlError( + "This Group Chat isn’t available here. Open it in Hermes Desktop or " + "another connected Hermes chat." + ) + return link + + +def _remote_error(exc: RoomControlClientError) -> RoomControlError: + if exc.status_code == 400 and exc.user_message: + return RoomControlError(exc.user_message) + if exc.status_code in {401, 403, 404}: + return RoomControlError( + "This Group Chat isn’t available here anymore. Open it in Hermes " + "Desktop or another connected Hermes chat." + ) + return RoomControlError( + "This Group Chat can’t be reached right now. Make sure the devices running " + "its Bots are online, then try again." + ) + + +def _remote_summary(service: Any, room: Mapping[str, Any]) -> dict[str, Any]: + link = _remote_control_link(service, room) + try: + summary = RoomControlHTTPClient(link).summary() + except RoomControlClientError as exc: + raise _remote_error(exc) from exc + raw_room = summary.get("room") + if not isinstance(raw_room, Mapping): + raise RoomControlError("This Group Chat returned invalid status data.") + try: + authority_epoch = int(raw_room.get("authority_epoch") or 0) + except (TypeError, ValueError) as exc: + raise RoomControlError("This Group Chat returned invalid status data.") from exc + if ( + str(raw_room.get("room_id") or "") != link.room_id + or str(raw_room.get("authority_gateway_id") or "") + != link.authority_gateway_id + or authority_epoch != link.authority_epoch + ): + raise RoomControlError("This Group Chat returned mismatched status data.") + raw_members = raw_room.get("members") + if not isinstance(raw_members, list) or not 1 <= len(raw_members) <= 64: + raise RoomControlError("This Group Chat returned invalid member data.") + hosted_room_controls.update_peer_control_metadata( + service.db_path, + room_id=link.room_id, + member_id=link.member_id, + authority_gateway_id=link.authority_gateway_id, + authority_epoch=link.authority_epoch, + room_name=raw_room.get("name") or link.room_name, + member_count=len(raw_members), + ) + return summary + + +def remote_mutate( + service: Any, + room: Mapping[str, Any], + *, + action: str, + command_id: str, + text: str = "", + actor_display_name: str = "Messaging", +) -> dict[str, Any]: + link = _remote_control_link(service, room) + try: + return RoomControlHTTPClient(link).mutate( + action=action, + command_id=command_id, + text=text, + actor_display_name=actor_display_name, + ) + except RoomControlClientError as exc: + raise _remote_error(exc) from exc + + +def _retry_receipt_plan( + db_path: Path, + *, + command_id: str, + room_id: str, + actor: Mapping[str, Any], + task_ids: list[str], +) -> tuple[list[str], str | None]: + """Freeze one transport delivery to one bounded retry decision.""" + + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path, timeout=10) + conn.row_factory = sqlite3.Row + try: + from hermes_state import apply_wal_with_fallback + + apply_wal_with_fallback(conn, db_label="state.db (Group Chat retry receipts)") + conn.execute("BEGIN IMMEDIATE") + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_messaging_retries ( + command_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + actor_json TEXT NOT NULL, + task_ids_json TEXT NOT NULL, + state TEXT NOT NULL, + result_text TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + encoded_actor = json.dumps( + dict(actor), ensure_ascii=True, sort_keys=True, separators=(",", ":") + ) + existing = conn.execute( + "SELECT * FROM hosted_room_messaging_retries WHERE command_id = ?", + (command_id,), + ).fetchone() + if existing is not None: + if ( + str(existing["room_id"]) != room_id + or str(existing["actor_json"]) != encoded_actor + ): + raise RoomControlError( + "This retry delivery was already used for different Group Chat work." + ) + frozen = [ + str(item) + for item in json.loads(str(existing["task_ids_json"])) + if str(item) + ] + result = ( + str(existing["result_text"]) + if existing["state"] == "completed" and existing["result_text"] + else None + ) + conn.commit() + return frozen, result + if not task_ids: + raise RoomControlError("This Group Chat has no failed work to retry.") + now = time.time() + conn.execute( + """INSERT INTO hosted_room_messaging_retries ( + command_id, room_id, actor_json, task_ids_json, state, + result_text, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', NULL, ?, ?)""", + ( + command_id, + room_id, + encoded_actor, + json.dumps(task_ids, ensure_ascii=True, separators=(",", ":")), + now, + now, + ), + ) + conn.commit() + return task_ids, None + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _complete_retry_receipt(db_path: Path, *, command_id: str, result: str) -> None: + conn = sqlite3.connect(db_path, timeout=10) + try: + conn.execute("BEGIN IMMEDIATE") + changed = conn.execute( + """UPDATE hosted_room_messaging_retries + SET state='completed', result_text=?, updated_at=? + WHERE command_id=? AND state='pending'""", + (result, time.time(), command_id), + ) + if changed.rowcount not in {0, 1}: + raise RuntimeError("Group Chat retry receipt changed more than once") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +@dataclass(frozen=True) +class RoomCommand: + """Parsed mutating ``/group`` subcommand.""" + + action: str + room_query: str + message: str = "" + + +class MessagingRoomBackend: + """Cross-process hosted-room access through the shared durable stores.""" + + def __init__(self, *, db_path: Any, service: Any = None) -> None: + self.db_path = db_path + self.service = service + + def status(self, room_id: str) -> dict[str, Any]: + from gateway import hosted_room_messaging_approvals as approvals + + if self.service is not None: + status = self.service.status(room_id) + peer_needs_attention = any( + str(route.get("status") or "") == "needs_reauthorization" + for route in status.get("peer_routes", []) + if isinstance(route, Mapping) + ) + actions = [ + action + for action in status.get("pending_actions", []) + if isinstance(action, Mapping) and action.get("kind") != "approval" + ] + actions.extend( + approvals.list_pending_approvals(self.db_path, room_id=room_id) + ) + return { + **status, + "blocked": bool(status.get("blocked") or peer_needs_attention), + "pending_actions": actions, + } + tasks = driver.list_tasks(self.db_path, room_id=room_id) + counts: dict[str, int] = {} + for task in tasks: + status = str(task.get("status") or "") + counts[status] = counts.get(status, 0) + 1 + try: + peer_needs_attention = any( + link.room_id == room_id and link.status == "needs_reauthorization" + for link in hosted_room_links.load_room_links(self.db_path) + ) + except Exception: + peer_needs_attention = True + return { + "working": any(counts.get(status) for status in ("queued", "running", "stopping")), + "blocked": bool(counts.get("indeterminate") or peer_needs_attention), + "counts": counts, + "pending_actions": [ + {"kind": "retry", "task_id": task["identity"].task_id} + for task in tasks + if task.get("status") in {"deferred", "indeterminate"} + ] + + approvals.list_pending_approvals(self.db_path, room_id=room_id), + } + + def send( + self, + *, + room_id: str, + event_id: str, + payload: Any, + actor: Mapping[str, Any], + ) -> dict[str, Any]: + if self.service is not None: + send_server_owned = getattr(self.service, "send_server_owned", None) + if callable(send_server_owned): + return send_server_owned( + room_id=room_id, + event_id=event_id, + payload=payload, + actor=actor, + ) + return self.service.send( + room_id=room_id, + event_id=event_id, + payload=payload, + actor=actor, + ) + room = hosted_rooms.room_state(self.db_path, room_id=room_id) + local_gateway_id = hosted_rooms.local_authority_gateway_id() + if str(room["authority_gateway_id"]) != local_gateway_id: + raise hosted_rooms.AuthorityConflictError( + "This Group Chat moved to another connected device. Open it there and try again." + ) + normalized = discussion.validate_user_payload(payload) + return hosted_rooms.append_event( + self.db_path, + room_id=room_id, + event_id=event_id, + kind="message.user", + actor=dict(actor), + payload=normalized, + authority_gateway_id=str(room["authority_gateway_id"]), + authority_epoch=int(room["authority_epoch"]), + ) + + def stop_room(self, room_id: str, *, cancel_id: str) -> int: + if self.service is not None: + return self.service.stop_room(room_id, cancel_id=cancel_id) + room = hosted_rooms.room_state(self.db_path, room_id=room_id) + local_gateway_id = hosted_rooms.local_authority_gateway_id() + if str(room["authority_gateway_id"]) != local_gateway_id: + raise hosted_rooms.AuthorityConflictError( + "This Group Chat moved to another connected device. Open it there and try again." + ) + hosted_rooms.request_room_stop( + self.db_path, + room_id=room_id, + cancel_id=cancel_id, + expected_gateway_id=local_gateway_id, + expected_epoch=int(room["authority_epoch"]), + ) + requested = 0 + for task in driver.list_tasks(self.db_path, room_id=room_id): + for _attempt in range(3): + current = driver.get_task(self.db_path, task["identity"]) + status = str(current.get("status") or "") + try: + if status == "queued": + driver.cancel_task( + self.db_path, + current["identity"], + cancel_id=cancel_id, + expected_cancel_generation=int( + current["cancel_generation"] + ), + clock=time.time, + ) + requested += 1 + elif status in {"running", "indeterminate", "deferred"}: + driver.begin_task_cancel( + self.db_path, + current["identity"], + cancel_id=cancel_id, + expected_cancel_generation=int( + current["cancel_generation"] + ), + clock=time.time, + ) + requested += 1 + elif status == "stopping": + requested += 1 + elif ( + status == "cancelled" + and current.get("cancel_id") == cancel_id + ): + requested += 1 + break + except (driver.InvalidTaskTransitionError, driver.StaleTaskError): + # Queued work can become running between the list and the + # write. Reload and retry under the new generation. + continue + return requested + + def retry_room_task( + self, + room_id: str, + *, + task_id: str, + retry_id: str | None = None, + ) -> dict[str, Any]: + if self.service is None: + raise RoomControlError( + "Retry is available when the device running this Group Chat is online." + ) + return self.service.retry_room_task( + room_id, + task_id=task_id, + retry_id=retry_id, + ) + + +def current_room_backend() -> MessagingRoomBackend: + """Resolve in-process service access or the shared cross-process store.""" + + from tui_gateway.methods_groups import get_hosted_room_service + + service = get_hosted_room_service() + return MessagingRoomBackend( + db_path=service.db_path if service is not None else hosted_rooms.default_db_path(), + service=service, + ) + + +def _clean_line(value: Any, *, limit: int = MAX_PREVIEW_CHARS) -> str: + """Collapse untrusted text to one bounded display line.""" + + text = re.sub(r"\s+", " ", str(value or "")).strip() + if len(text) <= limit: + return text + return text[: max(1, limit - 1)].rstrip() + "…" + + +def _plain_display_label(value: Any, *, limit: int = MAX_PREVIEW_CHARS) -> str: + """Neutralize markup-shaped labels before placing them in rich layouts.""" + + text = _clean_line(value, limit=limit) + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) + text = re.sub(r"[\\`*_{}\[\]#|>~]", "", text).strip() + return text.replace("@", "@") or "Unnamed" + + +def _plain_preview_text(value: Any, *, limit: int = MAX_PREVIEW_CHARS) -> str: + """Neutralize preview markup without deleting message content.""" + + text = _clean_line(value, limit=limit) + return text.translate( + str.maketrans( + { + "@": "@", + "\\": "\", + "`": "`", + "*": "*", + "_": "_", + "{": "{", + "}": "}", + "[": "[", + "]": "]", + "#": "#", + "|": "|", + ">": ">", + "~": "~", + } + ) + ) + + +def parse_room_command(args: str, *, command_root: str = "/group") -> RoomCommand: + """Parse the number-first send/retry/stop grammar used by messaging clients.""" + + raw = str(args or "").strip() + entity_first = raw.split(maxsplit=2) + if len(entity_first) < 2 or not entity_first[0].isdecimal(): + raise RoomControlError( + f"Use `{command_root} send `, " + f"`{command_root} retry`, or `{command_root} stop`." + ) + room_query = entity_first[0] + action = entity_first[1].casefold() + remainder = entity_first[2].strip() if len(entity_first) == 3 else "" + if action == "send": + message = remainder.removeprefix("--").strip() + if len(message) >= 2 and message[0] == message[-1] and message[0] in {'"', "'"}: + message = message[1:-1].strip() + if not message: + raise RoomControlError( + f"Use `{command_root} send `." + ) + return RoomCommand("send", room_query, message) + if action == "stop": + if remainder: + raise RoomControlError(f"Use `{command_root} stop`.") + return RoomCommand("stop", room_query) + if action == "retry": + if remainder: + raise RoomControlError(f"Use `{command_root} retry`.") + return RoomCommand("retry", room_query) + if action in {"approve", "deny"}: + if remainder and re.fullmatch(r"[A-Za-z0-9]{6,16}", remainder) is None: + raise RoomControlError( + f"Use `{command_root} {action} [approval code]`." + ) + return RoomCommand(action, room_query, remainder) + raise RoomControlError( + f"Use `{command_root} send `, " + f"`{command_root} approve`, `{command_root} deny`, " + f"`{command_root} retry`, or `{command_root} stop`." + ) + + +def resolve_room(rooms: list[dict[str, Any]], query: str) -> dict[str, Any]: + """Resolve by stable messaging number, then id/name convenience matches.""" + + needle = _clean_line(query, limit=hosted_rooms.MAX_ROOM_NAME_CHARS).casefold() + if not needle: + raise RoomControlError("Enter a room number or name.") + + if needle.isdecimal(): + numeric_ref = int(needle) + matches = [ + room for room in rooms if room.get("messaging_ref") == numeric_ref + ] + if len(matches) == 1: + return matches[0] + raise RoomControlError(f"No Group Chat is numbered {numeric_ref}.") + + if needle.startswith("id:"): + internal_id = needle.removeprefix("id:") + matches = [ + room + for room in rooms + if str(room.get("room_id") or "").casefold() == internal_id + ] + if len(matches) == 1: + return matches[0] + raise RoomControlError("No Group Chat matches that internal ID.") + + def _keys(room: Mapping[str, Any]) -> tuple[str, str]: + return ( + str(room.get("room_id") or "").casefold(), + str(room.get("name") or "").casefold(), + ) + + for match_kind in ("exact", "prefix", "contains"): + matches: list[dict[str, Any]] = [] + for room in rooms: + room_id, name = _keys(room) + if match_kind == "exact" and needle in {room_id, name}: + matches.append(room) + elif match_kind == "prefix" and ( + room_id.startswith(needle) or name.startswith(needle) + ): + matches.append(room) + elif match_kind == "contains" and (needle in room_id or needle in name): + matches.append(room) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + names = ", ".join( + ( + f"{_clean_line(room.get('name') or room.get('room_id'), limit=48)} " + f"[{room_reference(room)}]" + ) + for room in matches[:MAX_ROOM_CHOICES] + ) + suffix = "…" if len(matches) > MAX_ROOM_CHOICES else "" + raise RoomControlError( + f"That matches several group chats: {names}{suffix}. Enter more of the name." + ) + raise RoomControlError(f"No group chat matches “{_clean_line(query)}”.") + + +def _room_status(service: Any, room: Mapping[str, Any]) -> str: + if room.get("_room_mode") == "desktop": + command = room.get("desktop_command") + state = str(command.get("state") or "") if isinstance(command, Mapping) else "" + if state == "failed": + return "needs attention" + if state in {"pending", "claimed"} and room.get("desktop_available"): + return "applying command" + return "ready" if room.get("desktop_available") else "waiting for Desktop" + if room.get("_room_mode") == "remote": + return "connected" + room_id = str(room["room_id"]) + status = service.status(room_id) + if status.get("counts", {}).get("stopping"): + return "stopping" + if status.get("blocked"): + return "needs attention" + if status.get("working"): + return "work queued or running" + state = hosted_rooms.room_state(service.db_path, room_id=room_id) + since = max(0, int(state.get("latest_seq") or 0) - 80) + recent = hosted_rooms.read_events( + service.db_path, + room_id=room_id, + since_seq=since, + limit=80, + ).get("events", []) + latest_user = max( + (int(event["seq"]) for event in recent if event.get("kind") == "message.user"), + default=0, + ) + latest_boundary = max( + ( + int(event["seq"]) + for event in recent + if event.get("kind") in {"room.activity", "room.stop_requested"} + ), + default=0, + ) + if latest_user > latest_boundary: + return "waiting for its Bots" + return "idle" + + +def _room_action_flags( + service: Any, + room: Mapping[str, Any], + *, + remote_status: Mapping[str, Any] | None = None, +) -> tuple[bool, bool]: + """Return exact Retry/Stop availability from tasks or pending commands.""" + + if room.get("_room_mode") == "desktop": + command = room.get("desktop_command") + state = str(command.get("state") or "") if isinstance(command, Mapping) else "" + action = str(command.get("action") or "") if isinstance(command, Mapping) else "" + return ( + int(room.get("desktop_failed_commands") or 0) > 0, + action == "send" and state in {"claimed", "pending"}, + ) + + status: Mapping[str, Any] + if room.get("_room_mode") == "remote": + status = remote_status or {} + else: + raw_status = service.status(str(room["room_id"])) + status = raw_status if isinstance(raw_status, Mapping) else {} + raw_counts = status.get("counts") + counts = raw_counts if isinstance(raw_counts, Mapping) else {} + raw_actions = status.get("pending_actions") + actions = raw_actions if isinstance(raw_actions, list) else [] + retryable = any( + isinstance(action_row, Mapping) and action_row.get("kind") == "retry" + for action_row in actions + ) or bool(counts.get("deferred") or counts.get("indeterminate")) + stoppable = bool(status.get("working")) and not bool(counts.get("stopping")) + return retryable, stoppable + + +def format_room_list( + service: Any, + *, + rooms: list[dict[str, Any]] | None = None, + rooms_command: str = "/group", + page: int = 1, +) -> str: + """Render a bounded, scan-friendly Group Chat list.""" + + rooms = list_messaging_rooms(service) if rooms is None else list(rooms) + if not rooms: + return "👥 **No Group Chats yet**\nCreate one in Hermes Desktop first." + rooms = sorted(rooms, key=lambda room: int(room.get("messaging_ref") or 0)) + if not isinstance(page, int) or page < 1: + raise RoomControlError("Page numbers start at 1.") + page_count = max(1, (len(rooms) + MAX_ROOM_CHOICES - 1) // MAX_ROOM_CHOICES) + if page > page_count: + raise RoomControlError(f"There are only {page_count} Group Chat pages.") + start = (page - 1) * MAX_ROOM_CHOICES + visible_rooms = rooms[start : start + MAX_ROOM_CHOICES] + heading = "Group Chats" if page_count == 1 else f"Group Chats — page {page} of {page_count}" + lines = [f"👥 **{heading}**"] + for room in visible_rooms: + name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + member_count = _room_member_count(room) + status = _room_status(service, room) + lines.append( + f"{_room_status_icon(status)} **{room_reference(room)}. " + f"{_plain_display_label(name, limit=72)}** · " + f"{status} · {member_count} Bot{'s' if member_count != 1 else ''}" + ) + if page < page_count: + lines.append(f"More: `{rooms_command} list {page + 1}`") + elif page > 1: + lines.append(f"Previous: `{rooms_command} list {page - 1}`") + lines.extend( + [ + "", + "────────", + "🧭 **Controls**", + f"Check: `{rooms_command} `", + f"Send: `{rooms_command} send `", + f"Bots: `{rooms_command} bots`", + f"Retry: `{rooms_command} retry`", + f"Stop: `{rooms_command} stop`", + ] + ) + return "\n".join(lines) + + +def _event_label(event: Mapping[str, Any], member_names: Mapping[str, str]) -> str: + raw_actor = event.get("actor") + actor: Mapping[str, Any] = raw_actor if isinstance(raw_actor, Mapping) else {} + actor_id = str(actor.get("id") or "") + display_name = _clean_line(actor.get("display_name"), limit=48) + if display_name: + return display_name + if event.get("kind") == "message.member": + raw_payload = event.get("payload") + payload: Mapping[str, Any] = ( + raw_payload if isinstance(raw_payload, Mapping) else {} + ) + member_id = str(payload.get("member_id") or actor_id) + return member_names.get(member_id, "Bot") + return "You" + + +def format_room_detail( + service: Any, + room: Mapping[str, Any], + *, + room_command: str = "/group", +) -> str: + """Render status plus the latest visible room messages.""" + + room_id = str(room["room_id"]) + if not isinstance(room.get("messaging_ref"), int): + room = next( + ( + candidate + for candidate in list_messaging_rooms(service) + if str(candidate["room_id"]) == room_id + ), + dict(room), + ) + raw_members = room.get("members") + members: list[Any] = raw_members if isinstance(raw_members, list) else [] + desktop_mode = room.get("_room_mode") == "desktop" + remote_mode = room.get("_room_mode") == "remote" + if desktop_mode: + visible = [ + event for event in room.get("log", []) if isinstance(event, Mapping) + ][-MAX_RECENT_MESSAGES:] + member_names: dict[str, str] = {} + elif remote_mode: + summary = _remote_summary(service, room) + remote_room = summary["room"] + raw_remote_members = remote_room.get("members") + members = ( + list(raw_remote_members) + if isinstance(raw_remote_members, list) + else [] + ) + room = { + **room, + "name": remote_room.get("name") or room.get("name"), + "members": members, + "member_count": len(members), + "_remote_status": summary.get("status"), + } + member_names = { + str(member.get("member_id") or ""): _clean_line( + member.get("display_name") or member.get("handle") or "Bot", + limit=48, + ) + for member in members + if isinstance(member, Mapping) + } + raw_events = summary.get("events") + visible = [ + event + for event in (raw_events if isinstance(raw_events, list) else []) + if isinstance(event, Mapping) + and event.get("kind") in {"message.user", "message.member"} + ][-MAX_RECENT_MESSAGES:] + else: + state = hosted_rooms.room_state(service.db_path, room_id=room_id) + since = max(0, int(state.get("latest_seq") or 0) - 80) + delta = hosted_rooms.read_events( + service.db_path, + room_id=room_id, + since_seq=since, + limit=80, + ) + member_names = { + str(member.get("member_id") or ""): _clean_line( + member.get("display_name") or member.get("handle") or "Bot", + limit=48, + ) + for member in members + if isinstance(member, Mapping) + } + visible = [ + event + for event in delta.get("events", []) + if isinstance(event, Mapping) + and event.get("kind") in {"message.user", "message.member"} + ][-MAX_RECENT_MESSAGES:] + name = _clean_line(room.get("name") or room_id, limit=72) + status_text = _room_status(service, room) + action_status: Mapping[str, Any] | None = None + if remote_mode: + remote_status = room.get("_remote_status") + if isinstance(remote_status, Mapping): + action_status = remote_status + raw_counts = remote_status.get("counts") + counts = raw_counts if isinstance(raw_counts, Mapping) else {} + if counts.get("stopping"): + status_text = "stopping" + elif remote_status.get("blocked"): + status_text = "needs attention" + elif remote_status.get("working"): + status_text = "work queued or running" + else: + status_text = "idle" + lines = [ + f"💬 **{_plain_display_label(name, limit=72)}**", + f"{_room_status_icon(status_text)} {status_text}", + f"👥 {len(members)} Bot{'s' if len(members) != 1 else ''}", + ] + participant_lines = _room_participant_lines(room) + if participant_lines: + lines.extend(["", "🤖 **Bots**", *participant_lines]) + if visible: + lines.extend(["", "🕘 **Recent activity**"]) + for event in visible: + if desktop_mode: + source = event.get("from") if isinstance(event.get("from"), Mapping) else {} + label = _clean_line(source.get("name") or "You", limit=48) + text = event.get("text") + else: + payload = event.get("payload") if isinstance(event.get("payload"), Mapping) else {} + label = _event_label(event, member_names) + text = payload.get("text") + lines.append( + f"• **{_plain_display_label(label, limit=48)}:** " + f"{_plain_preview_text(text)}" + ) + else: + lines.extend(["", "No messages yet."]) + from gateway.hosted_room_messaging_approvals import format_pending_approvals + + approval_section = format_pending_approvals( + service, + room, + room_reference=str(room_reference(room)), + room_command=room_command, + ) + if approval_section: + lines.extend(["", approval_section]) + failed_commands = int(room.get("desktop_failed_commands") or 0) + show_retry, show_stop = _room_action_flags( + service, + room, + remote_status=action_status, + ) + if ( + desktop_mode + and failed_commands > 0 + ): + lines.append( + ( + "The latest command could not be applied." + if failed_commands == 1 + else f"{failed_commands} commands could not be applied." + ) + + " Retry here or open this Group Chat in Hermes Desktop." + ) + lines.extend(["", "────────", "🧭 **Controls**"]) + lines.append( + f"Send: `{room_command} {room_reference(room)} send `" + ) + lines.append(f"Bots: `{room_command} {room_reference(room)} bots`") + if show_retry: + lines.append(f"Retry: `{room_command} {room_reference(room)} retry`") + if show_stop: + lines.append(f"Stop: `{room_command} {room_reference(room)} stop`") + if _room_has_targetable_handle(room): + lines.append( + f"Message one Bot: `{room_command} {room_reference(room)} send @handle `" + ) + return "\n".join(lines) + + +def messaging_actor(event: Any, *, gateway_id: str) -> dict[str, str]: + """Build a stable actor without persisting raw platform user IDs.""" + + source = getattr(event, "source", None) + platform_value = getattr(getattr(source, "platform", None), "value", None) + platform = _clean_line(platform_value or "messaging", limit=32).casefold() + raw_user_id = ( + getattr(source, "user_id_alt", None) + or getattr(event, "user_id", None) + or getattr(source, "user_id", None) + or "unknown" + ) + scope = getattr(source, "scope_id", None) or getattr(source, "guild_id", None) + chat_id = getattr(source, "chat_id", None) + digest = hashlib.sha256( + f"{gateway_id}:{platform}:{scope or ''}:{chat_id or ''}:{raw_user_id}".encode() + ).hexdigest()[:20] + raw_name = _clean_line( + getattr(event, "user_name", None) or getattr(source, "user_name", None), + limit=48, + ) + platform_label = platform.replace("_", " ").title() + display_name = ( + f"{raw_name} via {platform_label}" + if raw_name and raw_name != str(raw_user_id) + else platform_label + ) + return { + "kind": "user", + "id": f"messaging:{platform}:{digest}", + "display_name": display_name, + } + + +def _raw_transport_id(event: Any) -> Any: + """Extract one adapter-owned redelivery key without guessing from text.""" + + metadata = getattr(event, "metadata", None) + candidates: list[Any] = [] + if isinstance(metadata, Mapping): + candidates.extend( + metadata.get(key) + for key in ( + "delivery_id", + "event_id", + "message_id", + "request_id", + "update_id", + ) + ) + raw = getattr(event, "raw_message", None) + payloads = [raw] + if isinstance(raw, Mapping): + payloads.extend( + raw.get(key) for key in ("event", "message", "data", "container") + ) + for payload in payloads: + if isinstance(payload, Mapping): + candidates.extend( + payload.get(key) + for key in ( + "client_msg_id", + "trigger_id", + "event_id", + "message_id", + "id", + "ts", + "event_ts", + "timestamp_ms", + "timestamp", + ) + ) + if raw is not None and not isinstance(raw, Mapping): + candidates.extend( + getattr(raw, key, None) for key in ("id", "interaction_id") + ) + return next( + ( + value + for value in candidates + if value is not None and str(value).strip() + ), + None, + ) + + +def messaging_event_id(event: Any) -> str: + """Return a deterministic idempotency key when the transport provides one.""" + + source = getattr(event, "source", None) + platform = getattr(getattr(source, "platform", None), "value", "messaging") + stable_message_id = ( + getattr(event, "message_id", None) + or getattr(source, "message_id", None) + or _raw_transport_id(event) + or getattr(event, "platform_update_id", None) + ) + if stable_message_id is None: + raise RoomControlError( + "This channel didn’t provide a stable message ID, so Hermes can’t " + "safely repeat this room command. Try another connected channel." + ) + material = "|".join( + str(value or "") + for value in ( + platform, + getattr(source, "chat_id", None), + getattr(source, "thread_id", None), + getattr(source, "user_id_alt", None) + or getattr(event, "user_id", None) + or getattr(source, "user_id", None), + stable_message_id, + ) + ) + return f"messaging:{hashlib.sha256(material.encode()).hexdigest()}" + + +def ensure_text_only(event: Any) -> None: + """Reject media explicitly until hosted-room attachment transport exists.""" + + source = getattr(event, "source", None) + if ( + getattr(source, "message_had_attachments", False) + or getattr(event, "media_urls", None) + or getattr(event, "media_types", None) + ): + raise RoomControlError( + "Attachments from messaging chats aren’t supported yet. Send text only." + ) + + +def is_machine_authored(event: Any) -> bool: + """Recognize native and relayed bot/webhook provenance defensively.""" + + source = getattr(event, "source", None) + if getattr(source, "is_bot", False): + return True + metadata = getattr(event, "metadata", None) + if isinstance(metadata, Mapping) and any( + metadata.get(key) is True + for key in ("is_bot", "sender_is_bot", "webhook_sender") + ): + return True + raw = getattr(event, "raw_message", None) + if isinstance(raw, Mapping): + if raw.get("bot_id") or raw.get("bot_profile"): + return True + if raw.get("subtype") in {"bot_message", "webhook_message"}: + return True + for owner_field in ("author", "user"): + owner = getattr(raw, owner_field, None) + if getattr(owner, "bot", False) or getattr(owner, "is_bot", False): + return True + return False + + +def is_message_edit(event: Any) -> bool: + """Reject edited commands even when a platform redelivers them as messages.""" + + source = getattr(event, "source", None) + if getattr(source, "message_is_edit", False): + return True + metadata = getattr(event, "metadata", None) + if isinstance(metadata, Mapping) and metadata.get("message_is_edit") is True: + return True + raw = getattr(event, "raw_message", None) + if isinstance(raw, Mapping): + if raw.get("editMessage") or raw.get("isEdited") is True: + return True + if raw.get("subtype") == "message_changed": + return True + relation = raw.get("m.relates_to") + if isinstance(relation, Mapping) and relation.get("rel_type") == "m.replace": + return True + return bool(getattr(raw, "edit_date", None) or getattr(raw, "edited_at", None)) + + +def relay_provenance_is_unknown(event: Any) -> bool: + """Fail closed until a relay producer classifies the inbound author.""" + + source = getattr(event, "source", None) + if not getattr(source, "delivered_via_upstream_relay", False): + return False + metadata = getattr(event, "metadata", None) + return not ( + isinstance(metadata, Mapping) + and metadata.get("relay_author_classified") is True + and metadata.get("relay_edit_classified") is True + ) + + +def _desktop_authority_hash(room: Mapping[str, Any]) -> str: + authority_hash = str(room.get("desktop_authority_hash") or "").strip().lower() + if not re.fullmatch(r"[a-f0-9]{64}", authority_hash): + raise RoomControlError( + "Open this Group Chat once in the latest Hermes Desktop, then try again." + ) + return authority_hash + + +def send_to_room(service: Any, room: Mapping[str, Any], event: Any, text: str) -> str: + """Append or hand off one idempotent room turn.""" + + ensure_text_only(event) + event_id = messaging_event_id(event) + name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + if room.get("_room_mode") == "desktop": + from gateway.desktop_room_mailbox import enqueue_command, default_db_path + + actor = messaging_actor( + event, + gateway_id=hosted_rooms.local_authority_gateway_id(), + ) + enqueue_command( + default_db_path(), + command_id=event_id, + room_id=str(room["room_id"]), + authority_hash=_desktop_authority_hash(room), + action="send", + payload={ + "message": text, + "actor_display_name": actor.get("display_name") or "Messaging", + "recipients": _frozen_desktop_recipients(room), + }, + ) + if room.get("desktop_available"): + return f"Queued in {name}." + return f"Saved for {name}. Open or update Hermes Desktop to continue." + if room.get("_room_mode") == "remote": + actor = messaging_actor( + event, + gateway_id=hosted_rooms.local_authority_gateway_id(), + ) + remote_mutate( + service, + room, + action="send", + command_id=event_id, + text=text, + actor_display_name=actor.get("display_name") or "Messaging", + ) + return f"Queued in {name}." + service.send( + room_id=str(room["room_id"]), + event_id=event_id, + payload={"text": text, "thread_id": event_id}, + actor=messaging_actor( + event, + gateway_id=str(room["authority_gateway_id"]), + ), + ) + return f"Queued in {name}." + + +def stop_room(service: Any, room: Mapping[str, Any], event: Any) -> str: + """Cancel active room tasks using a transport-derived idempotency key.""" + + cancel_id = f"stop:{messaging_event_id(event)}" + name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + if room.get("_room_mode") == "desktop": + from gateway.desktop_room_mailbox import ( + enqueue_command, + default_db_path, + latest_command_states, + ) + + room_id = str(room["room_id"]) + current = room.get("desktop_command") or latest_command_states( + default_db_path(), + [room_id], + ).get(room_id) + target_command_id = ( + str(current.get("command_id") or "") + if isinstance(current, Mapping) + and current.get("action") == "send" + and current.get("state") in {"claimed", "pending"} + else "" + ) + target_thread_id = _latest_projected_thread(room) + enqueue_command( + default_db_path(), + command_id=cancel_id, + room_id=room_id, + authority_hash=_desktop_authority_hash(room), + action="stop", + payload={ + **({"target_command_id": target_command_id} if target_command_id else {}), + **({"target_thread_id": target_thread_id} if target_thread_id else {}), + }, + ) + if room.get("desktop_available"): + return f"Stop requested for {name}." + return f"Stop saved for {name}. Open or update Hermes Desktop to apply it." + if room.get("_room_mode") == "remote": + remote_mutate( + service, + room, + action="stop", + command_id=cancel_id, + ) + return f"Stop requested for {name}. Active work will stop safely." + service.stop_room(str(room["room_id"]), cancel_id=cancel_id) + return f"Stop requested for {name}. Active work will stop safely." + + +def retry_room(service: Any, room: Mapping[str, Any], event: Any) -> str: + """Retry bounded failed work after an explicit owner command.""" + + name = _clean_line(room.get("name") or room.get("room_id"), limit=72) + room_id = str(room["room_id"]) + command_id = f"retry:{messaging_event_id(event)}" + actor = messaging_actor( + event, + gateway_id=( + hosted_rooms.local_authority_gateway_id() + if room.get("_room_mode") == "desktop" + else str(room.get("authority_gateway_id") or "") + ), + ) + receipt_db = Path(service.db_path) + if room.get("_room_mode") == "remote": + result = remote_mutate( + service, + room, + action="retry", + command_id=command_id, + actor_display_name=actor.get("display_name") or "Messaging", + ) + retried = result.get("retried") + if not isinstance(retried, int): + processed = result.get("processed") + if isinstance(processed, int): + suffix = "task" if processed == 1 else "tasks" + return f"Retry checked for {name} ({processed} {suffix})." + summary = result.get("summary") + retried = ( + int(summary.get("retried") or 0) + if isinstance(summary, Mapping) + else 0 + ) + suffix = "task" if retried == 1 else "tasks" + return f"Retry queued for {name} ({retried} {suffix})." + if room.get("_room_mode") == "desktop": + from gateway.desktop_room_mailbox import ( + default_db_path, + retry_failed_commands, + retryable_command_ids, + ) + + mailbox_db = default_db_path() + try: + frozen, completed = _retry_receipt_plan( + receipt_db, + command_id=command_id, + room_id=room_id, + actor=actor, + task_ids=[], + ) + except RoomControlError as exc: + if str(exc) != "This Group Chat has no failed work to retry.": + raise + target_ids = retryable_command_ids( + mailbox_db, + room_id=room_id, + ) + frozen, completed = _retry_receipt_plan( + receipt_db, + command_id=command_id, + room_id=room_id, + actor=actor, + task_ids=[target_id for target_id in target_ids if target_id], + ) + if completed: + return completed + retried = retry_failed_commands( + mailbox_db, + room_id=room_id, + command_ids=frozen, + ) + count = len(retried) + noun = "command" if count == 1 else "commands" + result = f"Retry queued for {name} ({count} {noun})." + _complete_retry_receipt(receipt_db, command_id=command_id, result=result) + return result + + pending = [ + action + for action in service.status(room_id).get("pending_actions", []) + if isinstance(action, Mapping) + and action.get("kind") == "retry" + and str(action.get("task_id") or "") + ] + frozen, completed = _retry_receipt_plan( + receipt_db, + command_id=command_id, + room_id=room_id, + actor=actor, + task_ids=[str(action["task_id"]) for action in pending[:MAX_ROOM_CHOICES]], + ) + if completed: + return completed + + def queue_for_worker() -> None: + hosted_room_controls.begin_control_retry( + receipt_db, + command_id=f"worker:{command_id}", + room_id=room_id, + member_id=str(actor.get("id") or "messaging"), + task_ids=list(frozen), + ) + + for task_id in frozen: + try: + service.retry_room_task( + room_id, + task_id=task_id, + retry_id=hosted_room_controls.control_retry_attempt_id( + command_id, + task_id, + ), + ) + except driver.LeaseHeldError: + queue_for_worker() + break + except RoomControlError as exc: + if str(exc) != ( + "Retry is available when the device running this Group Chat is online." + ): + raise + queue_for_worker() + break + except Exception: + still_pending = { + str(action.get("task_id") or "") + for action in service.status(room_id).get("pending_actions", []) + if isinstance(action, Mapping) and action.get("kind") == "retry" + } + if task_id in still_pending: + queue_for_worker() + break + count = len(frozen) + suffix = "task" if count == 1 else "tasks" + result = f"Retry queued for {name} ({count} {suffix})." + _complete_retry_receipt(receipt_db, command_id=command_id, result=result) + return result diff --git a/gateway/hosted_room_messaging_approvals.py b/gateway/hosted_room_messaging_approvals.py new file mode 100644 index 0000000000000..dfab543ad726f --- /dev/null +++ b/gateway/hosted_room_messaging_approvals.py @@ -0,0 +1,1062 @@ +"""Durable mobile approval journal for gateway-hosted Group Chats.""" + +from __future__ import annotations + +import hashlib +import json +import re +import sqlite3 +import time +from collections.abc import Mapping +from pathlib import Path +from typing import Any + + +MAX_PENDING_APPROVALS = 8 +MAX_APPROVAL_TEXT_CHARS = 512 +PENDING_APPROVAL_TTL_SECONDS = 24 * 60 * 60 +COMMAND_RETENTION_SECONDS = 7 * 24 * 60 * 60 +MAX_PENDING_COMMANDS_PER_ROOM = MAX_PENDING_APPROVALS +MAX_PENDING_COMMANDS_TOTAL = 512 +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") +_APPROVAL_SCOPE_FIELDS = ( + "room_id", + "authority_gateway_id", + "authority_epoch", + "member_id", + "task_id", + "execution_generation", + "request_id", +) + + +class MessagingApprovalError(ValueError): + """A messaging approval was malformed, stale, or reused inconsistently.""" + + +class MessagingApprovalTerminalError(RuntimeError): + """An exact approval can no longer be applied and must not be retried.""" + + +class MessagingApprovalObservationStale(RuntimeError): + """A worker observation lost its exact room lease before mutation.""" + + +def _identifier(value: Any, *, label: str) -> str: + normalized = str(value or "").strip() + if _IDENTIFIER_RE.fullmatch(normalized) is None: + raise MessagingApprovalError(f"invalid {label}") + return normalized + + +def _text(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip()[:MAX_APPROVAL_TEXT_CHARS] + + +def _display_text(value: Any, *, limit: int) -> str: + """Keep untrusted labels readable without letting them impersonate controls.""" + + return _text(value)[:limit].translate( + str.maketrans( + { + "@": "@", + "\\": "\", + "`": "`", + "*": "*", + "_": "_", + "{": "{", + "}": "}", + "[": "[", + "]": "]", + "#": "#", + "|": "|", + ">": ">", + "~": "~", + } + ) + ) + + +def _connect(db_path: Path | str) -> sqlite3.Connection: + conn = sqlite3.connect(db_path, timeout=10) + conn.row_factory = sqlite3.Row + from hermes_state import apply_wal_with_fallback + + apply_wal_with_fallback(conn, db_label="state.db (Group Chat approvals)") + _initialize(conn) + return conn + + +def _initialize(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_pending_approvals ( + room_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + member_id TEXT NOT NULL, + task_id TEXT NOT NULL, + execution_generation INTEGER NOT NULL, + request_id TEXT NOT NULL, + profile TEXT NOT NULL, + session_id TEXT NOT NULL, + observer_generation TEXT NOT NULL, + observer_lease_generation INTEGER NOT NULL, + description TEXT NOT NULL, + command_text TEXT NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (room_id, member_id) + )""" + ) + pending_columns = { + str(row[1]) + for row in conn.execute("PRAGMA table_info(hosted_room_pending_approvals)") + } + if not {"observer_generation", "observer_lease_generation"} <= pending_columns: + try: + conn.execute("BEGIN IMMEDIATE") + pending_columns = { + str(row[1]) + for row in conn.execute( + "PRAGMA table_info(hosted_room_pending_approvals)" + ) + } + if "observer_generation" not in pending_columns: + conn.execute( + """ALTER TABLE hosted_room_pending_approvals + ADD COLUMN observer_generation TEXT NOT NULL DEFAULT 'legacy'""" + ) + if "observer_lease_generation" not in pending_columns: + conn.execute( + """ALTER TABLE hosted_room_pending_approvals + ADD COLUMN observer_lease_generation INTEGER NOT NULL DEFAULT 0""" + ) + conn.commit() + except Exception: + conn.rollback() + raise + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_messaging_approval_commands ( + command_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + member_id TEXT NOT NULL, + task_id TEXT NOT NULL, + execution_generation INTEGER NOT NULL, + request_id TEXT NOT NULL, + choice TEXT NOT NULL CHECK (choice IN ('once', 'deny')), + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + result_text TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + UNIQUE ( + room_id, authority_gateway_id, authority_epoch, + member_id, task_id, + execution_generation, request_id + ) + )""" + ) + + +def _prune_locked(conn: sqlite3.Connection, *, now: float) -> None: + conn.execute( + "DELETE FROM hosted_room_pending_approvals WHERE updated_at None: + observer = str(observation.get("observer_generation") or "legacy") + if observer == "legacy": + return + lease_generation = int(observation.get("observer_lease_generation") or 0) + if lease_generation < 1: + raise MessagingApprovalObservationStale("approval observer lease is unavailable") + try: + room = conn.execute( + """SELECT authority_gateway_id, authority_epoch, disbanded_at + FROM hosted_rooms WHERE room_id=?""", + (str(observation.get("room_id") or ""),), + ).fetchone() + row = conn.execute( + """SELECT gateway_id, authority_epoch, process_generation, + lease_generation, expires_at, released_at + FROM hosted_room_driver_leases WHERE room_id=?""", + (str(observation.get("room_id") or ""),), + ).fetchone() + except sqlite3.OperationalError as exc: + if "no such table" in str(exc).casefold(): + raise MessagingApprovalObservationStale( + "approval observer lease is unavailable" + ) from exc + raise + if ( + room is None + or room["disbanded_at"] is not None + or str(room["authority_gateway_id"]) + != str(observation.get("authority_gateway_id") or "") + or int(room["authority_epoch"]) + != int(observation.get("authority_epoch") or 0) + or row is None + or str(row["gateway_id"]) != str(observation.get("authority_gateway_id") or "") + or int(row["authority_epoch"]) != int(observation.get("authority_epoch") or 0) + or str(row["process_generation"]) != observer + or int(row["lease_generation"]) != lease_generation + or row["released_at"] is not None + or float(row["expires_at"]) <= now + ): + raise MessagingApprovalObservationStale("approval observer lease changed") + + +def normalize_pending_approval( + room_id: Any, + member_id: Any, + action: Mapping[str, Any], +) -> dict[str, Any]: + if action.get("kind") != "approval": + raise MessagingApprovalError("pending action is not an approval") + approval = action.get("approval") + if not isinstance(approval, Mapping): + raise MessagingApprovalError("pending approval details are unavailable") + choices = { + str(choice or "").casefold() for choice in approval.get("choices") or () + } + if not {"once", "deny"} <= choices: + raise MessagingApprovalError("pending approval choices are unsafe") + generation = int(action.get("execution_generation") or 0) + if generation < 1: + raise MessagingApprovalError("pending approval generation is invalid") + authority_epoch = int(action.get("authority_epoch") or 0) + if authority_epoch < 1: + raise MessagingApprovalError("pending approval authority epoch is invalid") + return { + "kind": "approval", + "room_id": _identifier(room_id, label="room_id"), + "authority_gateway_id": _identifier( + action.get("authority_gateway_id"), + label="authority_gateway_id", + ), + "authority_epoch": authority_epoch, + "member_id": _identifier(member_id, label="member_id"), + "task_id": _identifier(action.get("task_id"), label="task_id"), + "execution_generation": generation, + "request_id": _identifier(action.get("request_id"), label="request_id"), + "profile": ( + _identifier(action.get("profile"), label="profile") + if action.get("profile") + else "" + ), + "session_id": ( + _identifier(action.get("session_id"), label="session_id") + if action.get("session_id") + else "" + ), + "observer_generation": _identifier( + action.get("observer_generation") or "legacy", + label="observer_generation", + ), + "observer_lease_generation": int( + action.get("observer_lease_generation") or 0 + ), + "approval": { + "description": _text(approval.get("description")), + "command": _text(approval.get("command")), + "choices": ["once", "deny"], + }, + } + + +def persist_pending_approval( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + action: Mapping[str, Any], +) -> dict[str, Any]: + pending = normalize_pending_approval(room_id, member_id, action) + approval = pending["approval"] + conn = _connect(db_path) + try: + conn.execute("BEGIN IMMEDIATE") + now = time.time() + _prune_locked(conn, now=now) + _require_observer_lease(conn, pending, now=now) + conn.execute( + """INSERT INTO hosted_room_pending_approvals( + room_id, authority_gateway_id, authority_epoch, + member_id, task_id, execution_generation, + request_id, profile, session_id, description, + observer_generation, observer_lease_generation, + command_text, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(room_id, member_id) DO UPDATE SET + authority_gateway_id=excluded.authority_gateway_id, + authority_epoch=excluded.authority_epoch, + task_id=excluded.task_id, + execution_generation=excluded.execution_generation, + request_id=excluded.request_id, + profile=excluded.profile, + session_id=excluded.session_id, + observer_generation=excluded.observer_generation, + observer_lease_generation=excluded.observer_lease_generation, + description=excluded.description, + command_text=excluded.command_text, + updated_at=excluded.updated_at""", + ( + pending["room_id"], + pending["authority_gateway_id"], + pending["authority_epoch"], + pending["member_id"], + pending["task_id"], + pending["execution_generation"], + pending["request_id"], + pending["profile"], + pending["session_id"], + approval["description"], + pending["observer_generation"], + pending["observer_lease_generation"], + approval["command"], + now, + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + return pending + + +def clear_pending_approval( + db_path: Path | str, + *, + room_id: Any, + member_id: Any, + request_id: Any | None = None, + authority_gateway_id: Any | None = None, + authority_epoch: Any | None = None, + observer_generation: Any | None = None, + observer_lease_generation: Any | None = None, +) -> int: + room = _identifier(room_id, label="room_id") + member = _identifier(member_id, label="member_id") + request = ( + _identifier(request_id, label="request_id") + if request_id is not None + else "" + ) + authority_gateway = ( + _identifier(authority_gateway_id, label="authority_gateway_id") + if authority_gateway_id is not None + else "" + ) + epoch = int(authority_epoch or 0) + if authority_epoch is not None and epoch < 1: + raise MessagingApprovalError("authority_epoch must be positive") + observer = str(observer_generation or "") + observer_lease = int(observer_lease_generation or 0) + conn = _connect(db_path) + try: + conn.execute("BEGIN IMMEDIATE") + if observer: + _require_observer_lease( + conn, + { + "room_id": room, + "authority_gateway_id": authority_gateway, + "authority_epoch": epoch, + "observer_generation": observer, + "observer_lease_generation": observer_lease, + }, + now=time.time(), + ) + changed = conn.execute( + """DELETE FROM hosted_room_pending_approvals + WHERE room_id=? AND member_id=? + AND (?='' OR request_id=?) + AND (?='' OR authority_gateway_id=?) + AND (?=0 OR authority_epoch=?)""", + ( + room, + member, + request, + request, + authority_gateway, + authority_gateway, + epoch, + epoch, + ), + ) + conn.commit() + return int(changed.rowcount) + finally: + conn.close() + + +def _pending_from_row(row: sqlite3.Row) -> dict[str, Any]: + return { + "kind": "approval", + "room_id": str(row["room_id"]), + "authority_gateway_id": str(row["authority_gateway_id"]), + "authority_epoch": int(row["authority_epoch"]), + "member_id": str(row["member_id"]), + "task_id": str(row["task_id"]), + "execution_generation": int(row["execution_generation"]), + "request_id": str(row["request_id"]), + "profile": str(row["profile"]), + "session_id": str(row["session_id"]), + "observer_generation": str(row["observer_generation"]), + "observer_lease_generation": int(row["observer_lease_generation"]), + "approval": { + "description": str(row["description"]), + "command": str(row["command_text"]), + "choices": ["once", "deny"], + }, + } + + +def list_pending_approvals( + db_path: Path | str, + *, + room_id: Any, +) -> list[dict[str, Any]]: + room = _identifier(room_id, label="room_id") + conn = _connect(db_path) + try: + rows = conn.execute( + """SELECT * FROM hosted_room_pending_approvals + WHERE room_id=? AND updated_at>=? + ORDER BY updated_at, member_id LIMIT ?""", + (room, time.time() - PENDING_APPROVAL_TTL_SECONDS, MAX_PENDING_APPROVALS), + ).fetchall() + finally: + conn.close() + return [_pending_from_row(row) for row in rows] + + +def list_all_pending_approvals( + db_path: Path | str, + *, + limit: int = 512, +) -> list[dict[str, Any]]: + conn = _connect(db_path) + try: + rows = conn.execute( + """SELECT * FROM hosted_room_pending_approvals + WHERE updated_at>=? + ORDER BY updated_at, room_id, member_id LIMIT ?""", + ( + time.time() - PENDING_APPROVAL_TTL_SECONDS, + max(1, min(512, int(limit))), + ), + ).fetchall() + finally: + conn.close() + return [_pending_from_row(row) for row in rows] + + +def begin_approval_command( + db_path: Path | str, + *, + command_id: Any, + pending: Mapping[str, Any], + choice: str, +) -> dict[str, Any]: + command = _identifier(command_id, label="command_id") + normalized_choice = str(choice or "").casefold() + if normalized_choice not in {"once", "deny"}: + raise MessagingApprovalError("approval choice must be once or deny") + authority_epoch = int(pending.get("authority_epoch") or 0) + execution_generation = int(pending.get("execution_generation") or 0) + coordinates = ( + _identifier(pending.get("room_id"), label="room_id"), + _identifier( + pending.get("authority_gateway_id"), + label="authority_gateway_id", + ), + authority_epoch, + _identifier(pending.get("member_id"), label="member_id"), + _identifier(pending.get("task_id"), label="task_id"), + execution_generation, + _identifier(pending.get("request_id"), label="request_id"), + ) + if authority_epoch < 1: + raise MessagingApprovalError("approval authority epoch is invalid") + if execution_generation < 1: + raise MessagingApprovalError("approval generation is invalid") + now = time.time() + conn = _connect(db_path) + try: + conn.execute("BEGIN IMMEDIATE") + _prune_locked(conn, now=now) + existing = conn.execute( + "SELECT * FROM hosted_room_messaging_approval_commands WHERE command_id=?", + (command,), + ).fetchone() + if existing is not None: + stored = ( + str(existing["room_id"]), + str(existing["authority_gateway_id"]), + int(existing["authority_epoch"]), + str(existing["member_id"]), + str(existing["task_id"]), + int(existing["execution_generation"]), + str(existing["request_id"]), + str(existing["choice"]), + ) + if stored != (*coordinates, normalized_choice): + raise MessagingApprovalError( + "approval command ID was reused with different content" + ) + conn.commit() + return { + "command_id": command, + "state": str(existing["state"]), + "result": existing["result_text"], + "idempotent": True, + **dict(zip(_APPROVAL_SCOPE_FIELDS, coordinates)), + "choice": normalized_choice, + } + existing_scope = conn.execute( + """SELECT * FROM hosted_room_messaging_approval_commands + WHERE room_id=? AND authority_gateway_id=? AND authority_epoch=? + AND member_id=? AND task_id=? + AND execution_generation=? AND request_id=?""", + coordinates, + ).fetchone() + if existing_scope is not None: + if str(existing_scope["choice"]) != normalized_choice: + raise MessagingApprovalError( + "A different decision is already queued for this approval." + ) + conn.commit() + return { + "command_id": str(existing_scope["command_id"]), + "state": str(existing_scope["state"]), + "result": existing_scope["result_text"], + "idempotent": True, + **dict(zip(_APPROVAL_SCOPE_FIELDS, coordinates)), + "choice": normalized_choice, + } + pending_room_count = conn.execute( + """SELECT COUNT(*) FROM hosted_room_messaging_approval_commands + WHERE room_id=? AND state='pending'""", + (coordinates[0],), + ).fetchone()[0] + pending_total_count = conn.execute( + """SELECT COUNT(*) FROM hosted_room_messaging_approval_commands + WHERE state='pending'""", + ).fetchone()[0] + if ( + int(pending_room_count) >= MAX_PENDING_COMMANDS_PER_ROOM + or int(pending_total_count) >= MAX_PENDING_COMMANDS_TOTAL + ): + raise MessagingApprovalError( + "Too many approval decisions are waiting. Try again later." + ) + conn.execute( + """INSERT INTO hosted_room_messaging_approval_commands( + command_id, room_id, authority_gateway_id, authority_epoch, + member_id, task_id, + execution_generation, request_id, choice, state, + result_text, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?)""", + (command, *coordinates, normalized_choice, now, now), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + return { + "command_id": command, + "state": "pending", + "result": None, + "idempotent": False, + **dict(zip(_APPROVAL_SCOPE_FIELDS, coordinates)), + "choice": normalized_choice, + } + + +def list_pending_approval_commands( + db_path: Path | str, + *, + room_id: Any, +) -> list[dict[str, Any]]: + room = _identifier(room_id, label="room_id") + conn = _connect(db_path) + try: + rows = conn.execute( + """SELECT * FROM hosted_room_messaging_approval_commands + WHERE room_id=? AND state='pending' AND updated_at>=? + ORDER BY created_at, command_id LIMIT ?""", + ( + room, + time.time() - PENDING_APPROVAL_TTL_SECONDS, + MAX_PENDING_APPROVALS, + ), + ).fetchall() + finally: + conn.close() + return [dict(row) for row in rows] + + +def list_all_pending_approval_commands( + db_path: Path | str, + *, + limit: int = MAX_PENDING_COMMANDS_TOTAL, +) -> list[dict[str, Any]]: + conn = _connect(db_path) + try: + rows = conn.execute( + """SELECT * FROM hosted_room_messaging_approval_commands + WHERE state='pending' AND updated_at>=? + ORDER BY created_at, command_id LIMIT ?""", + ( + time.time() - PENDING_APPROVAL_TTL_SECONDS, + max(1, min(MAX_PENDING_COMMANDS_TOTAL, int(limit))), + ), + ).fetchall() + finally: + conn.close() + return [dict(row) for row in rows] + + +def terminalize_unowned_approval_commands( + db_path: Path | str, + *, + local_gateway_id: str, +) -> int: + """Close decisions that no live local room binding can ever consume.""" + + from gateway import hosted_rooms + + completed = 0 + for command in list_all_pending_approval_commands(db_path): + reason = "" + try: + room = hosted_rooms.room_state(db_path, room_id=command["room_id"]) + except (hosted_rooms.RoomNotFoundError, hosted_rooms.RoomQuarantinedError): + reason = "Approval expired because the Group Chat is no longer available." + else: + if ( + str(room["authority_gateway_id"]) != local_gateway_id + or str(command["authority_gateway_id"]) + != str(room["authority_gateway_id"]) + or int(command["authority_epoch"]) != int(room["authority_epoch"]) + ): + reason = "Approval expired because Group Chat authority changed." + if not reason: + continue + clear_pending_approval( + db_path, + room_id=command["room_id"], + member_id=command["member_id"], + request_id=command["request_id"], + authority_gateway_id=command["authority_gateway_id"], + authority_epoch=command["authority_epoch"], + ) + complete_approval_command( + db_path, + command_id=command["command_id"], + result=reason, + ) + completed += 1 + return completed + + +def approval_command( + db_path: Path | str, + *, + command_id: Any, +) -> dict[str, Any] | None: + command = _identifier(command_id, label="command_id") + conn = _connect(db_path) + try: + row = conn.execute( + """SELECT * FROM hosted_room_messaging_approval_commands + WHERE command_id=? + AND ((state='pending' AND updated_at>=?) + OR (state='completed' AND updated_at>=?))""", + ( + command, + time.time() - PENDING_APPROVAL_TTL_SECONDS, + time.time() - COMMAND_RETENTION_SECONDS, + ), + ).fetchone() + finally: + conn.close() + return dict(row) if row is not None else None + + +def complete_approval_command( + db_path: Path | str, + *, + command_id: Any, + result: str, +) -> None: + command = _identifier(command_id, label="command_id") + safe_result = _text(result) + conn = _connect(db_path) + try: + now = time.time() + conn.execute("BEGIN IMMEDIATE") + _prune_locked(conn, now=now) + conn.execute( + """UPDATE hosted_room_messaging_approval_commands + SET state='completed', result_text=?, updated_at=? + WHERE command_id=? AND state='pending'""", + (safe_result, now, command), + ) + conn.commit() + finally: + conn.close() + + +def submit_approval( + db_path: Path | str, + *, + service: Any, + command_id: Any, + pending: Mapping[str, Any], + choice: str, +) -> dict[str, Any]: + """Freeze a decision, resolving immediately only when this process owns it.""" + + plan = begin_approval_command( + db_path, + command_id=command_id, + pending=pending, + choice=choice, + ) + if plan["state"] == "completed": + return { + **plan, + "queued": False, + "applied": plan.get("result") in {"Approved once.", "Denied."}, + } + if service is not None: + try: + service.approve_room_task( + plan["room_id"], + member_id=plan["member_id"], + task_id=plan["task_id"], + execution_generation=plan["execution_generation"], + choice=plan["choice"], + request_id=plan["request_id"], + ) + except MessagingApprovalTerminalError as exc: + result = _text(exc) or "Approval is no longer available." + complete_approval_command( + db_path, + command_id=plan["command_id"], + result=result, + ) + return { + **plan, + "state": "completed", + "result": result, + "queued": False, + "applied": False, + } + except Exception: + return {**plan, "queued": True} + else: + result = "Approved once." if plan["choice"] == "once" else "Denied." + try: + complete_approval_command( + db_path, + command_id=plan["command_id"], + result=result, + ) + except Exception: + return {**plan, "queued": True, "applied": True} + return { + **plan, + "state": "completed", + "result": result, + "queued": False, + "applied": True, + } + return {**plan, "queued": True} + + +def pending_approvals_for_room( + service: Any, + room: Mapping[str, Any], +) -> list[dict[str, Any]]: + if room.get("_room_mode") == "desktop": + return [] + if room.get("_room_mode") == "remote": + return [] + else: + raw_actions = service.status(str(room["room_id"])).get("pending_actions", []) + actions = raw_actions if isinstance(raw_actions, list) else [] + normalized = [ + normalize_pending_approval( + room["room_id"], + action.get("member_id"), + action, + ) + for action in actions + if isinstance(action, Mapping) and action.get("kind") == "approval" + ] + authority_gateway_id = str(room.get("authority_gateway_id") or "") + authority_epoch = int(room.get("authority_epoch") or 0) + if authority_gateway_id and authority_epoch: + normalized = [ + action + for action in normalized + if action["authority_gateway_id"] == authority_gateway_id + and action["authority_epoch"] == authority_epoch + ] + return normalized[:MAX_PENDING_APPROVALS] + + +def approval_reference(pending: Mapping[str, Any]) -> str: + coordinates = "\0".join(str(pending[field]) for field in _APPROVAL_SCOPE_FIELDS) + return hashlib.sha256(coordinates.encode()).hexdigest()[:8].upper() + + +def select_pending_approval( + pending: list[dict[str, Any]], + selection: str = "", +) -> tuple[int, dict[str, Any]]: + raw = str(selection or "").strip() + matches = [ + (index, action) + for index, action in enumerate(pending, start=1) + if approval_reference(action).casefold() == raw.casefold() + ] + if len(matches) != 1: + raise MessagingApprovalError("Choose the approval code shown in the Group Chat.") + return matches[0] + + +def submit_room_approval( + service: Any, + room: Mapping[str, Any], + *, + command_id: str, + choice: str, + selection: str = "", + expected_request_id: str = "", +) -> tuple[int, dict[str, Any], dict[str, Any]]: + if room.get("_room_mode") == "desktop": + raise MessagingApprovalError( + "This Group Chat runs in Desktop. Approve or deny the command there." + ) + if room.get("_room_mode") == "remote": + raise MessagingApprovalError( + "Approvals are available only in an owner chat connected to the " + "device running this Group Chat." + ) + existing = approval_command(service.db_path, command_id=command_id) + if existing is not None: + result = submit_approval( + service.db_path, + service=service.service, + command_id=command_id, + pending=existing, + choice=choice, + ) + return 0, existing, result + pending = pending_approvals_for_room(service, room) + if not pending: + raise MessagingApprovalError("This Group Chat has no pending approvals.") + if expected_request_id: + matches = [ + (candidate_index, action) + for candidate_index, action in enumerate(pending, start=1) + if action["request_id"] == expected_request_id + ] + if len(matches) != 1: + raise MessagingApprovalError( + "That approval changed. Check the Group Chat again." + ) + index, selected = matches[0] + else: + index, selected = select_pending_approval(pending, selection) + result = submit_approval( + service.db_path, + service=service.service, + command_id=command_id, + pending=selected, + choice=choice, + ) + return index, selected, result + + +def approval_member_label(room: Mapping[str, Any], member_id: str) -> str: + for member in room.get("members") or []: + if not isinstance(member, Mapping): + continue + candidate = str(member.get("member_id") or member.get("profile") or "") + if candidate != member_id: + continue + return _display_text( + member.get("display_name") + or member.get("handle") + or member.get("profile") + or member_id, + limit=48, + ) + return _display_text(member_id, limit=48) or "Bot" + + +def _approval_member_picker_label(room: Mapping[str, Any], member_id: str) -> str: + label = approval_member_label(room, member_id) + for member in room.get("members") or []: + if not isinstance(member, Mapping): + continue + candidate = str(member.get("member_id") or member.get("profile") or "") + if candidate != member_id: + continue + handle = _display_text( + str(member.get("handle") or "").lstrip("@"), + limit=20, + ) + if handle: + suffix = f" · @{handle}" + return f"{label[: max(1, 40 - len(suffix))]}{suffix}" + break + suffix = f" · {member_id[:10]}" + return f"{label[: max(1, 40 - len(suffix))]}{suffix}" + + +def _approval_display_parts(approval: Mapping[str, Any]) -> tuple[str, str]: + command = _display_text( + approval.get("command"), + limit=MAX_APPROVAL_TEXT_CHARS, + ) + description = _display_text( + approval.get("description"), + limit=160 if command else MAX_APPROVAL_TEXT_CHARS, + ) + if command == description: + command = "" + return description, command + + +def format_pending_approvals( + service: Any, + room: Mapping[str, Any], + *, + room_reference: str, + room_command: str = "/group", +) -> str: + pending = pending_approvals_for_room(service, room) + if not pending: + return "" + lines = ["⚠️ **Approval needed**"] + for index, action in enumerate(pending, start=1): + approval = action["approval"] + label = approval_member_label(room, str(action["member_id"])) + description, command = _approval_display_parts(approval) + detail = description or command or "Command" + reference = approval_reference(action) + lines.append( + f"{index}. **{label}** · {detail} · `{reference}`" + ) + if command and command != detail: + lines.append(f" Command: {command}") + lines.extend([ + f"Actions: `{room_command} {room_reference} approvals`", + f"Approve once: `{room_command} {room_reference} approve `", + f"Deny: `{room_command} {room_reference} deny `", + ]) + return "\n".join(lines) + + +def format_approval_picker_title( + room: Mapping[str, Any], + pending: list[dict[str, Any]], +) -> str: + lines = ["⚠️ **Approval needed**"] + for index, action in enumerate(pending, start=1): + bot = approval_member_label(room, str(action["member_id"])) + approval = action["approval"] + description, command = _approval_display_parts(approval) + lines.append(f"{index}. **{bot}**: {description or command or 'Command'}") + if command and command != description: + lines.append(f" Command: {command}") + lines.append("Choose **Approve once** or **Deny** below.") + return "\n".join(lines) + + +def approval_picker_choices( + room: Mapping[str, Any], + pending: list[dict[str, Any]], +) -> list[dict[str, Any]]: + if not 1 <= len(pending) <= 4: + return [] + choices: list[dict[str, Any]] = [] + for index, action in enumerate(pending, start=1): + picker_bot = _approval_member_picker_label( + room, + str(action["member_id"]), + ) + coordinates = "\0".join( + str(action[field]) + for field in _APPROVAL_SCOPE_FIELDS + ) + once_token = hashlib.sha256( + f"{index}\0once\0{coordinates}".encode() + ).hexdigest()[:20] + deny_token = hashlib.sha256( + f"{index}\0deny\0{coordinates}".encode() + ).hexdigest()[:20] + choices.extend([ + { + "value": f"a={index}.o.{once_token}", + "label": f"✓ {index}. Approve once · {picker_bot}", + "description": "Approve this command one time", + "full_width": True, + "is_current": False, + }, + { + "value": f"a={index}.d.{deny_token}", + "label": f"✕ {index}. Deny · {picker_bot}", + "description": "Do not run this command", + "full_width": True, + "is_current": False, + }, + ]) + return choices + + +def resolve_approval_picker_choice( + room: Mapping[str, Any], + pending: list[dict[str, Any]], + value: str, +) -> tuple[int, str, str]: + choices = approval_picker_choices(room, pending) + matched = next( + (choice for choice in choices if choice["value"] == str(value or "")), + None, + ) + if matched is None: + raise MessagingApprovalError( + "That approval changed. Check the Group Chat again." + ) + _prefix, encoded = str(matched["value"]).split("=", 1) + index_text, choice_code, _digest = encoded.split(".", 2) + index = int(index_text) + choice = "once" if choice_code == "o" else "deny" + return index, choice, str(pending[index - 1]["request_id"]) diff --git a/gateway/hosted_room_policy_checkpoint.py b/gateway/hosted_room_policy_checkpoint.py index e141b0e158e2a..b9f474573ac20 100644 --- a/gateway/hosted_room_policy_checkpoint.py +++ b/gateway/hosted_room_policy_checkpoint.py @@ -632,18 +632,27 @@ def events_for_task( WHERE room_id=? AND seq=?""", (room_id, source_event_seq), ).fetchone() + if source is None: + source = conn.execute( + """SELECT thread_id + FROM hosted_room_policy_transcript + WHERE room_id=? AND seq=?""", + (room_id, source_event_seq), + ).fetchone() + active_rows = [] + else: + active_rows = conn.execute( + """SELECT event_json FROM hosted_room_policy_events + WHERE room_id=? AND discussion_event_id=? + ORDER BY seq LIMIT ?""", + ( + room_id, + str(source["discussion_event_id"]), + MAX_ACTIVE_POLICY_EVENTS + 1, + ), + ).fetchall() if source is None: return [] - active_rows = conn.execute( - """SELECT event_json FROM hosted_room_policy_events - WHERE room_id=? AND discussion_event_id=? - ORDER BY seq LIMIT ?""", - ( - room_id, - str(source["discussion_event_id"]), - MAX_ACTIVE_POLICY_EVENTS + 1, - ), - ).fetchall() transcript_events = self._transcript_events( conn, room_id=room_id, diff --git a/gateway/hosted_room_storage.py b/gateway/hosted_room_storage.py index f72075cd0a94a..280f6b6bcd912 100644 --- a/gateway/hosted_room_storage.py +++ b/gateway/hosted_room_storage.py @@ -1344,6 +1344,9 @@ def _prune_disbanded_rooms_locked( "hosted_room_driver_leases", "hosted_room_remote_runs", "hosted_room_links", + "hosted_room_control_commands", + "hosted_room_control_tokens", + "hosted_room_peer_controls", "hosted_room_peer_reservations", "hosted_room_events", ) diff --git a/gateway/platforms/api_server_room_controls.py b/gateway/platforms/api_server_room_controls.py new file mode 100644 index 0000000000000..cd77275f77a80 --- /dev/null +++ b/gateway/platforms/api_server_room_controls.py @@ -0,0 +1,368 @@ +"""Private, room-scoped control API for participating RoomLink gateways.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +try: + from aiohttp import web +except ImportError: + web = None # type: ignore[assignment] + +from gateway import hosted_room_controls, hosted_rooms +from gateway.hosted_room_messaging import MessagingRoomBackend + + +MAX_CONTROL_TEXT_CHARS = 64 * 1024 +MAX_CONTROL_EVENTS = 5 +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") + + +def _http_routes(self) -> list[tuple[str, str, Any]]: + async def read(request): + from gateway.platforms import api_server + + return await _handle_room_control_read( + self, + request, + _openai_error=api_server._openai_error, + ) + + async def mutate(request): + from gateway.platforms import api_server + + return await _handle_room_control_mutate( + self, + request, + _openai_error=api_server._openai_error, + ) + + async def revoke(request): + from gateway.platforms import api_server + + return await _handle_room_control_revoke( + self, + request, + _openai_error=api_server._openai_error, + ) + + return [ + ("GET", "/v1/room-controls/{room_id}", read), + ("POST", "/v1/room-controls/{room_id}", mutate), + ("DELETE", "/v1/room-controls/{room_id}", revoke), + ] + + +def _control_token(request: "web.Request") -> str: + authorization = str(request.headers.get("Authorization") or "") + scheme, separator, token = authorization.partition(" ") + if not separator or scheme.casefold() != "hermesroomcontrol": + return "" + return token.strip() + + +def _identifier(value: Any, *, label: str) -> str: + normalized = str(value or "").strip() + if not _IDENTIFIER_RE.fullmatch(normalized): + raise ValueError(f"invalid {label}") + return normalized + + +def _authorize(request: "web.Request") -> tuple[dict[str, Any], str]: + try: + room_id = _identifier(request.match_info.get("room_id"), label="room_id") + member_id = _identifier( + request.headers.get("X-Hermes-Room-Member"), + label="room member", + ) + except ValueError as exc: + raise PermissionError("room control scope is missing") from exc + token = _control_token(request) + if not token: + raise PermissionError("room control credential is missing") + try: + room = hosted_rooms.room_state( + hosted_rooms.default_db_path(), + room_id=room_id, + ) + except hosted_rooms.RoomNotFoundError as exc: + raise PermissionError("room control credential is invalid") from exc + if not hosted_room_controls.verify_home_control_token( + hosted_rooms.default_db_path(), + room_id=room_id, + member_id=member_id, + authority_gateway_id=str(room["authority_gateway_id"]), + authority_epoch=int(room["authority_epoch"]), + control_token=token, + ): + raise PermissionError("room control credential is invalid") + return room, member_id + + +def _backend() -> MessagingRoomBackend: + from tui_gateway.methods_groups import get_hosted_room_service + + service = get_hosted_room_service() + return MessagingRoomBackend( + db_path=(service.db_path if service is not None else hosted_rooms.default_db_path()), + service=service, + ) + + +def _visible_events(room_id: str) -> list[dict[str, Any]]: + state = hosted_rooms.room_state(hosted_rooms.default_db_path(), room_id=room_id) + delta = hosted_rooms.read_events( + hosted_rooms.default_db_path(), + room_id=room_id, + since_seq=max(0, int(state.get("latest_seq") or 0) - 80), + limit=80, + ) + visible: list[dict[str, Any]] = [] + for event in delta.get("events", []): + if not isinstance(event, dict) or event.get("kind") not in { + "message.member", + "message.user", + }: + continue + raw_actor = event.get("actor") + actor = raw_actor if isinstance(raw_actor, Mapping) else {} + raw_payload = event.get("payload") + payload = raw_payload if isinstance(raw_payload, Mapping) else {} + visible.append( + { + "kind": event["kind"], + "actor": { + "id": str(actor.get("id") or "")[:256], + "display_name": str(actor.get("display_name") or "")[:128], + }, + "payload": { + "member_id": str(payload.get("member_id") or "")[:256], + "text": str(payload.get("text") or "")[:MAX_CONTROL_TEXT_CHARS], + }, + } + ) + return visible[-MAX_CONTROL_EVENTS:] + + +def _summary(room: Mapping[str, Any], backend: MessagingRoomBackend) -> dict[str, Any]: + room_id = str(room["room_id"]) + raw_status = backend.status(room_id) + raw_counts = raw_status.get("counts") + counts = raw_counts if isinstance(raw_counts, Mapping) else {} + members = [] + for raw_member in list(room.get("members") or []): + if not isinstance(raw_member, Mapping): + continue + members.append( + { + "member_id": str(raw_member.get("member_id") or "")[:256], + "handle": str(raw_member.get("handle") or "")[:128], + "display_name": str(raw_member.get("display_name") or "")[:128], + } + ) + return { + "room": { + "room_id": room_id, + "name": str(room.get("name") or room_id), + "members": members, + "authority_gateway_id": str(room["authority_gateway_id"]), + "authority_epoch": int(room["authority_epoch"]), + "latest_seq": int(room.get("latest_seq") or 0), + }, + "status": { + "working": raw_status.get("working") is True, + "blocked": raw_status.get("blocked") is True, + "counts": { + status: int(counts.get(status) or 0) + for status in ( + "queued", + "running", + "stopping", + "deferred", + "indeterminate", + "settled", + "failed", + "cancelled", + ) + if int(counts.get(status) or 0) > 0 + }, + }, + "events": _visible_events(room_id), + } + + +def _error_response(_openai_error, message: str, *, status: int, code: str): + return web.json_response( + _openai_error(message, code=code), + status=status, + ) + + +async def _handle_room_control_read( + self, + request: "web.Request", + *, + _openai_error, +) -> "web.Response": + try: + room, _member_id = _authorize(request) + result = _summary(room, _backend()) + except PermissionError: + return _error_response( + _openai_error, + "Room control is unavailable or expired.", + status=401, + code="invalid_room_control", + ) + except hosted_rooms.RoomNotFoundError: + return _error_response( + _openai_error, + "Group Chat not found.", + status=404, + code="room_not_found", + ) + except Exception: + return _error_response( + _openai_error, + "Group Chat status could not be loaded.", + status=409, + code="room_control_unavailable", + ) + return web.json_response(result) + + +async def _handle_room_control_mutate( + self, + request: "web.Request", + *, + _openai_error, +) -> "web.Response": + try: + room, member_id = _authorize(request) + body, body_error = await self._read_json_body(request) + if body_error: + return body_error + if not isinstance(body, Mapping): + raise ValueError("room control body must be an object") + allowed = {"action", "actor_display_name", "command_id", "text"} + if set(body) - allowed or not {"action", "command_id"} <= set(body): + raise ValueError("room control fields are invalid") + action = str(body.get("action") or "").casefold() + command_id = _identifier(body.get("command_id"), label="command_id") + room_id = str(room["room_id"]) + backend = _backend() + display_name = str(body.get("actor_display_name") or "Messaging").strip() + display_name = re.sub(r"\s+", " ", display_name)[:128] or "Messaging" + + if action == "send": + text = str(body.get("text") or "").strip() + if not text or len(text) > MAX_CONTROL_TEXT_CHARS: + raise ValueError("Group Chat message is empty or too large") + event = backend.send( + room_id=room_id, + event_id=command_id, + payload={"text": text, "thread_id": command_id}, + actor={ + "kind": "user", + "id": f"peer:{member_id}", + "display_name": display_name, + }, + ) + result = {"action": "send", "event": event} + elif action == "stop": + cancelled = backend.stop_room(room_id, cancel_id=f"control:{command_id}") + result = {"action": "stop", "cancelled": int(cancelled)} + elif action == "retry": + pending = [ + str(item.get("task_id") or "") + for item in backend.status(room_id).get("pending_actions", []) + if isinstance(item, Mapping) + and item.get("kind") == "retry" + and str(item.get("task_id") or "") + ][:8] + plan = hosted_room_controls.begin_control_retry( + hosted_rooms.default_db_path(), + command_id=command_id, + room_id=room_id, + member_id=member_id, + task_ids=pending, + ) + if plan.result is not None: + result = plan.result + else: + result = { + "action": "retry", + "queued": True, + "retried": len(plan.task_ids), + } + else: + raise ValueError("room control action must be send, retry, or stop") + except PermissionError: + return _error_response( + _openai_error, + "Room control is unavailable or expired.", + status=401, + code="invalid_room_control", + ) + except hosted_rooms.RoomNotFoundError: + return _error_response( + _openai_error, + "Group Chat not found.", + status=404, + code="room_not_found", + ) + except (ValueError, hosted_room_controls.HostedRoomControlError) as exc: + return _error_response( + _openai_error, + str(exc), + status=400, + code="invalid_room_control", + ) + except Exception: + return _error_response( + _openai_error, + "Group Chat control could not be applied.", + status=409, + code="room_control_unavailable", + ) + return web.json_response({**result, "summary": _summary(room, backend)}) + + +async def _handle_room_control_revoke( + self, + request: "web.Request", + *, + _openai_error, +) -> "web.Response": + try: + room_id = _identifier(request.match_info.get("room_id"), label="room_id") + member_id = _identifier( + request.headers.get("X-Hermes-Room-Member"), + label="room member", + ) + token = _control_token(request) + if not token: + raise PermissionError("room control credential is missing") + revoked = hosted_room_controls.revoke_home_control_token_value( + hosted_rooms.default_db_path(), + room_id=room_id, + member_id=member_id, + control_token=token, + ) + except (PermissionError, ValueError, hosted_room_controls.HostedRoomControlError): + return _error_response( + _openai_error, + "Room control is unavailable or expired.", + status=401, + code="invalid_room_control", + ) + except Exception: + return _error_response( + _openai_error, + "Group Chat control could not be revoked.", + status=409, + code="room_control_unavailable", + ) + return web.json_response({"revoked": int(revoked)}) diff --git a/gateway/platforms/api_server_room_grants.py b/gateway/platforms/api_server_room_grants.py index 6acb4c37463b1..eff92852bf3c9 100644 --- a/gateway/platforms/api_server_room_grants.py +++ b/gateway/platforms/api_server_room_grants.py @@ -1,5 +1,6 @@ """RoomLink room-member grants and capability HTTP handlers.""" +import asyncio import time import uuid from typing import Any @@ -49,7 +50,7 @@ def _room_grant_error_response(exc: Exception, *, _openai_error) -> "web.Respons def _http_routes(self) -> list[tuple[str, str, Any]]: - return [ + routes = [ ( "POST", "/v1/room-members/invitations", @@ -71,6 +72,10 @@ def _http_routes(self) -> list[tuple[str, str, Any]]: self._handle_room_member_grant_revoke, ), ] + from gateway.platforms import api_server_room_controls + + routes.extend(api_server_room_controls._http_routes(self)) + return routes def _room_grant_token(request: "web.Request") -> str: @@ -391,6 +396,7 @@ async def _handle_room_member_grant_revoke( self._room_grant_secret(), token, permission="status", + allow_expired_for_revocation=True, ) profile = _api_request_profile.get() or "default" installation_id = hosted_rooms.local_authority_gateway_id() @@ -415,6 +421,23 @@ async def _handle_room_member_grant_revoke( ), status=401, ) + try: + from gateway.hosted_room_control_client import revoke_stored_peer_control + + await asyncio.to_thread( + revoke_stored_peer_control, + hosted_rooms.default_db_path(), + room_id=str(claims["room_id"]), + member_id=str(claims["member_id"]), + ) + except Exception: + return web.json_response( + _openai_error( + "Room control cleanup is pending; retry this revocation.", + code="room_control_cleanup_pending", + ), + status=503, + ) return web.json_response( { "object": "hermes.room_member.grant.revocation", diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 4e46f2b2b2d9a..560e459806d62 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -742,6 +742,9 @@ async def _handle_envelope(self, envelope: dict) -> None: user_id_alt=sender_uuid if sender_uuid else None, chat_id_alt=group_id if is_group else None, ) + source.is_one_to_one = not is_group + source.message_is_edit = envelope_data.get("editMessage") is not None + source.message_had_attachments = bool(attachments_data) # Determine message type from media msg_type = MessageType.TEXT diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py index 09e7b0b64f886..24750cb6784aa 100644 --- a/gateway/platforms/whatsapp_common.py +++ b/gateway/platforms/whatsapp_common.py @@ -495,7 +495,6 @@ def _header_to_bold(m: re.Match) -> str: result = result.replace(f"{_FENCE_PH}{i}\x00", fence) for i, code in enumerate(codes): result = result.replace(f"{_CODE_PH}{i}\x00", code) - return result diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index 5e90887c57a4e..b49130028a7e6 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -289,6 +289,7 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: scope_id=src.get("scope_id"), parent_chat_id=src.get("parent_chat_id"), message_id=src.get("message_id"), + is_bot=src.get("is_bot") if isinstance(src.get("is_bot"), bool) else False, # The HERMES profile this event is routed to (multiplex mode). The # connector stamps it on the wire source when NAS resolves the target # profile for a Team-Gateway message; absent for a single-profile @@ -319,6 +320,24 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: # ``Platform.RELAY``). Stamped here, never read off the wire. delivered_via_upstream_relay=True, ) + verified_one_to_one = src.get("one_to_one_verified") + if isinstance(verified_one_to_one, bool): + # The connector is authenticated to this relay socket, so this wire-only + # proof becomes a transport-local fact and is never persisted/replayed. + source.is_one_to_one = verified_one_to_one + elif str(src.get("chat_type") or "").casefold() in {"dm", "direct", "private"} and platform_enum in { + Platform.DISCORD, + Platform.SIGNAL, + Platform.TELEGRAM, + Platform.WHATSAPP, + Platform.WHATSAPP_CLOUD, + }: + # These adapters use a distinct non-DM chat type for multi-party chats. + # Slack MPIM and Matrix m.direct are deliberately excluded. + source.is_one_to_one = True + relay_edit = src.get("message_is_edit") + if isinstance(relay_edit, bool): + source.message_is_edit = relay_edit try: msg_type = MessageType(raw.get("message_type", "text")) except ValueError: @@ -336,6 +355,10 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: text=text, message_type=msg_type, source=source, + metadata={ + "relay_author_classified": isinstance(src.get("is_bot"), bool), + "relay_edit_classified": isinstance(relay_edit, bool), + }, message_id=raw.get("message_id"), reply_to_message_id=raw.get("reply_to_message_id"), # Richer quoted-reply context (Phase 4): what the user replied TO, diff --git a/gateway/run.py b/gateway/run.py index 405217d4cf08d..c924b557e900a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17829,6 +17829,7 @@ def _gateway_plain_command_handlers(self): "bg": self._handle_background_command, "btw": self._handle_btw_command, "kanban": self._handle_kanban_command, + "group": self._handle_rooms_command, "subgoal": self._handle_subgoal_command, "heartbeat": self._handle_heartbeat_command, "busy": self._handle_busy_command, @@ -19197,6 +19198,12 @@ async def _do_reset(): if canonical == "personality": return await self._handle_personality_command(event) + if canonical == "kanban": + return await self._handle_kanban_command(event) + + if canonical == "group": + return await self._handle_rooms_command(event) + if canonical == "suggestions": return await self._handle_suggestions_command(event) diff --git a/gateway/session.py b/gateway/session.py index 568322d83f546..620aaf27d1d9d 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -185,6 +185,10 @@ class SessionSource: # Transport-local fail-closed signal for an explicit profile route whose # target is not served. Excluded from repr/equality and wire serialization. profile_route_rejected: bool = field(default=False, repr=False, compare=False) + # Transport-local trust facts; never deserialize these from stored/wire data. + is_one_to_one: Optional[bool] = field(default=None, repr=False, compare=False) + message_is_edit: bool = field(default=False, repr=False, compare=False) + message_had_attachments: bool = field(default=False, repr=False, compare=False) # Discord auto-thread metadata. Newly auto-created Discord threads start # with a fast placeholder title from the raw message, then the gateway can @@ -260,6 +264,7 @@ def to_dict(self) -> Dict[str, Any]: "user_name": self.user_name, "thread_id": self.thread_id, "chat_topic": self.chat_topic, + "is_bot": self.is_bot, } if self.user_id_alt: d["user_id_alt"] = self.user_id_alt @@ -305,6 +310,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": scope_id=data.get("scope_id", data.get("guild_id")), parent_chat_id=data.get("parent_chat_id"), message_id=data.get("message_id"), + is_bot=bool(data.get("is_bot", False)), profile=data.get("profile"), auto_thread_created=bool(data.get("auto_thread_created", False)), auto_thread_initial_name=data.get("auto_thread_initial_name"), diff --git a/gateway/slash_access.py b/gateway/slash_access.py index e4a398dc14a19..c0732636730c7 100644 --- a/gateway/slash_access.py +++ b/gateway/slash_access.py @@ -222,8 +222,60 @@ def policy_for_source(gateway_config: Any, source: Any) -> SlashAccessPolicy: return policy_from_extra(extra, scope) +def is_home_control_source(gateway_config: Any, source: Any) -> bool: + """Return whether *source* is the configured home chat's exact operator. + + The home chat is the operator-selected control surface (``/sethome``). + Matching it here avoids making that same operator maintain a second + slash-admin allowlist for owner-only controls. A shared home chat is valid + only when ``/sethome`` stored the selecting user's identity; other members + never inherit that authority. + """ + if gateway_config is None or source is None: + return False + scope = _scope_for_chat_type(getattr(source, "chat_type", None)) + if scope not in {"dm", "group"}: + return False + if getattr(source, "is_bot", False) is True: + return False + + platform = getattr(source, "platform", None) + user_id = getattr(source, "user_id", None) + chat_id = getattr(source, "chat_id", None) + if platform is None or not user_id or not chat_id: + return False + + try: + home = gateway_config.get_home_channel(platform) + except Exception: + return False + if home is None or str(home.chat_id) != str(chat_id): + return False + + home_user_id = getattr(home, "user_id", None) + if scope == "group" and not home_user_id: + return False + if home_user_id and str(home_user_id) != str(user_id): + return False + home_scope_id = getattr(home, "scope_id", None) + source_scope_id = getattr(source, "scope_id", None) + if home_scope_id and str(home_scope_id) != str(source_scope_id or ""): + return False + return True + + +def is_home_dm_source(gateway_config: Any, source: Any) -> bool: + """Backward-compatible DM-only wrapper for existing callers.""" + + if _scope_for_chat_type(getattr(source, "chat_type", None)) != "dm": + return False + return is_home_control_source(gateway_config, source) + + __all__ = [ "SlashAccessPolicy", "policy_from_extra", "policy_for_source", + "is_home_control_source", + "is_home_dm_source", ] diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 5493e849dcb59..86f0618c674d8 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -33,6 +33,7 @@ from agent.i18n import t from agent.turn_context import extract_api_content_sidecar from gateway.config import HomeChannel, Platform, PlatformConfig, persist_home_channel +from gateway.group_chat_slash import GroupChatSlashCommandsMixin from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType from gateway.session import ( AsyncSessionStore, @@ -55,7 +56,6 @@ # its worker thread. (#35994) _RESET_CLEANUP_TIMEOUT_S = 30.0 - def _clean_str(value: Any) -> str: """Strip and return a non-empty string value, or empty string.""" return value.strip() if isinstance(value, str) and value.strip() else "" @@ -123,7 +123,7 @@ def _home_thread_from_source(source) -> Optional[str]: return str(thread_id) -class GatewaySlashCommandsMixin: +class GatewaySlashCommandsMixin(GroupChatSlashCommandsMixin): """In-session slash-command handlers for GatewayRunner.""" async_session_store: AsyncSessionStore @@ -573,6 +573,13 @@ def _sub(): output = output[:3800] + "\n" + t("gateway.kanban.truncated_suffix") return output or t("gateway.kanban.no_output") + + + + + + + async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model @@ -3878,9 +3885,12 @@ async def _try_send_choice_picker( if not has_picker: return False try: - metadata = self._thread_metadata_for_source( + metadata = dict(self._thread_metadata_for_source( event.source, self._reply_anchor_for_event(event) - ) + ) or {}) + requester_user_id = getattr(event.source, "user_id", None) + if requester_user_id is not None: + metadata["requester_user_id"] = str(requester_user_id) result = await adapter.send_choice_picker( chat_id=event.source.chat_id, title=title, diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 23af46886d51f..6bfc51dbc4603 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -203,6 +203,10 @@ class CommandDef: args_hint="", busy_policy="dispatch"), CommandDef("agents", "Show active agents and running tasks", "Session", aliases=("tasks",), busy_policy="dispatch"), + CommandDef("group", "List, inspect, or control Bot Group Chats", "Bots", + gateway_only=True, + args_hint="[list [page] | number | number send message | number retry | number stop]", + busy_policy="dispatch"), CommandDef("journey", "Open the learning journey timeline", "Session", aliases=("learning", "memory-graph"), cli_only=True, args_hint="[list|delete |edit ]", @@ -1478,7 +1482,7 @@ def discord_skill_commands_by_category( # (session export is an interactive surface; platform is a rare # informational lookup) — without this entry /save tips the registry # past the 50-cap and silently clamps /platform, breaking parity. -_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "review", "pause", "whoami", "platform", "insights"}) +_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "review", "pause", "whoami", "platform", "insights", "group"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index a98b016bfc574..0e5747145a7d0 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -6489,6 +6489,8 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes thread_id=thread_id, chat_topic=chat_topic, ) + source.is_one_to_one = is_dm + source.message_is_edit = False msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT channel_id = str(interaction.channel_id) @@ -7949,8 +7951,10 @@ async def send_choice_picker( if not channel: channel = await self._client.fetch_channel(int(target_id)) + navigation = any(choice.get("full_width") for choice in choices) + first_line = title.splitlines()[0] if title else "Choose an option" embed = discord.Embed( - title="⚙ " + (title.splitlines()[0] if title else "Choose an option"), + title=first_line if navigation else f"⚙ {first_line}", description="\n".join(title.splitlines()[1:]) or None, color=discord.Color.blue(), ) @@ -7960,6 +7964,9 @@ async def send_choice_picker( on_choice_selected=on_choice_selected, allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, + requester_user_id=str((metadata or {}).get("requester_user_id") or "") + or None, + navigation=navigation, ) msg = await channel.send(embed=embed, view=view) @@ -8378,6 +8385,8 @@ async def _handle_message( or self._derive_auto_thread_name(message.content or "") ) if auto_threaded_channel is not None else None, ) + source.is_one_to_one = isinstance(message.channel, discord.DMChannel) + source.message_is_edit = getattr(message, "edited_at", None) is not None # Build media URLs -- download image attachments to local cache so the # vision tool can access them reliably (Discord CDN URLs can expire). @@ -9626,12 +9635,16 @@ def __init__( on_choice_selected, allowed_user_ids: set, allowed_role_ids: Optional[set] = None, + requester_user_id: Optional[str] = None, + navigation: bool = False, ): super().__init__(timeout=120) self.choices = list(choices)[:_DISCORD_SELECT_MAX_OPTIONS] self.on_choice_selected = on_choice_selected self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() + self.requester_user_id = requester_user_id + self.navigation = navigation self.resolved = False self._message = None @@ -9655,6 +9668,10 @@ def __init__( self.add_item(select) def _check_auth(self, interaction: discord.Interaction) -> bool: + if self.requester_user_id and self.requester_user_id != str( + getattr(getattr(interaction, "user", None), "id", "") + ): + return False return _component_check_auth( interaction, self.allowed_user_ids, self.allowed_role_ids, ) @@ -9662,7 +9679,11 @@ def _check_auth(self, interaction: discord.Interaction) -> bool: async def _on_select(self, interaction: discord.Interaction): if not self._check_auth(interaction): await interaction.response.send_message( - "⛔ You are not authorized to change this setting.", + ( + "⛔ You are not authorized to use this menu." + if self.navigation + else "⛔ You are not authorized to change this setting." + ), ephemeral=True, ) return @@ -9682,7 +9703,11 @@ async def _on_select(self, interaction: discord.Interaction): embed = discord.Embed( description=result_text, - color=discord.Color.green(), + color=( + discord.Color.blue() + if self.navigation + else discord.Color.green() + ), ) self.clear_items() self.stop() @@ -9695,7 +9720,11 @@ async def on_timeout(self): if msg is not None: try: embed = discord.Embed( - description="⏱ Selection expired — no change made.", + description=( + "⏱ Menu expired — run the command again." + if self.navigation + else "⏱ Selection expired — no change made." + ), color=discord.Color.greyple(), ) self.clear_items() diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 3268fd9d1930d..7f04e35abd715 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -3457,7 +3457,15 @@ async def _resolve_message_context( guild_id=identity.server_name, parent_chat_id=room_id if thread_id else None, message_id=event_id, + is_bot=bool(sender and sender == self._user_id), ) + joined_member_count = getattr(identity, "joined_member_count", None) + source.is_one_to_one = bool( + chat_type == "dm" + and joined_member_count is not None + and joined_member_count <= 2 + ) + source.message_is_edit = False if thread_id: self._threads.mark(thread_id) diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index 6962fbf615075..3712803ecdd0d 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -994,6 +994,7 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None: thread_id=thread_id, message_id=post_id, ) + source.message_had_attachments = bool(file_ids) # Per-channel ephemeral prompt from gateway.platforms.base import resolve_channel_prompt diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index cd8e237d3b5ff..d8a0d6da1bf18 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -6033,6 +6033,7 @@ async def _handle_slack_message_impl( self, event: dict, payload: Optional[dict] = None ) -> None: """Handle an incoming Slack message event.""" + is_message_edit = event.get("subtype") == "message_changed" # DEBUG entry log — fires BEFORE any filtering so users debugging # bot-to-bot interop, allow_bots config, or SLACK_ALLOWED_USERS # drops can confirm whether the event actually arrived from Slack @@ -7049,8 +7050,12 @@ async def _handle_slack_message_impl( # subtype=bot_message with user=None; flag them so the # gateway SLACK_ALLOW_BOTS bypass can authorize them # (they carry no user_id to match against the allowlist). - is_bot=bool(event.get("bot_id")) or event.get("subtype") == "bot_message", + is_bot=sender_is_bot, ) + # Transport-local privacy and replay signals. They are intentionally not + # serialized: an older relay cannot assert a private one-to-one surface. + source.is_one_to_one = is_one_to_one_dm + source.message_is_edit = is_message_edit # Per-channel ephemeral prompt from gateway.platforms.base import ( @@ -7110,6 +7115,7 @@ async def _handle_slack_message_impl( "slack_team_id": team_id, "slack_channel_id": channel_id, "slack_thread_ts": thread_ts, + "message_is_edit": is_message_edit, }, ) @@ -8545,6 +8551,8 @@ async def _handle_slash_command(self, command: dict) -> None: thread_id=thread_id, scope_id=team_id or None, ) + source.is_one_to_one = is_dm + source.message_is_edit = False event = MessageEvent( text=text, diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index b8ed8d11f0dc6..c42ccb95ea44f 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -6751,9 +6751,10 @@ async def send_choice_picker( ) if not buttons: return SendResult(success=False, error="No choices") - # Two buttons per row keeps labels readable on mobile. + row_size = 1 if any(choice.get("full_width") for choice in choices) else 2 + # Settings stay compact; navigation choices can request a full row. keyboard = InlineKeyboardMarkup( - [buttons[i:i + 2] for i in range(0, len(buttons), 2)] + [buttons[i:i + row_size] for i in range(0, len(buttons), row_size)] ) thread_id = metadata.get("thread_id") if metadata else None @@ -6775,8 +6776,10 @@ async def send_choice_picker( ) self._choice_picker_state[str(chat_id)] = { + "expires_at": time.monotonic() + 120, "msg_id": msg.message_id, "choices": choices, + "requester_user_id": str((metadata or {}).get("requester_user_id") or ""), "session_key": session_key, "on_choice_selected": on_choice_selected, } @@ -6799,6 +6802,20 @@ async def _handle_choice_picker_callback( # picker message. query_message = getattr(query, "message", None) query_chat = getattr(query_message, "chat", None) + query_message_id = getattr(query_message, "message_id", None) + if query_message_id != state.get("msg_id"): + await query.answer(text="This menu has expired. Run the command again.") + return + if time.monotonic() > float(state.get("expires_at") or 0): + self._choice_picker_state.pop(chat_id, None) + await query.answer(text="This menu has expired. Run the command again.") + return + requester_user_id = str(state.get("requester_user_id") or "") + if requester_user_id and requester_user_id != str( + getattr(query.from_user, "id", "") + ): + await query.answer(text="⛔ This menu belongs to another user.") + return if not self._is_callback_user_authorized( str(getattr(query.from_user, "id", "")), chat_id=getattr(query_message, "chat_id", None), @@ -10894,6 +10911,10 @@ def _build_message_event( message_id=str(message.message_id), is_bot=bool(getattr(user, "is_bot", False)) if user else False, ) + source.is_one_to_one = ( + str(getattr(chat, "type", "") or "").casefold() == "private" + ) + source.message_is_edit = getattr(message, "edit_date", None) is not None # Extract reply context if this message is a reply. # Prefer Telegram's native partial quote (message.quote, TextQuote) diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 2749217a1b034..b2ac42d5c2e4c 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -1519,7 +1519,8 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv msg_type = MessageType.DOCUMENT # Determine chat type - is_group = data.get("isGroup", False) + raw_is_group = data.get("isGroup") + is_group = raw_is_group is True chat_type = "group" if is_group else "dm" # Build source @@ -1529,6 +1530,13 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv chat_type=chat_type, user_id=data.get("senderId"), user_name=data.get("senderName"), + is_bot=data.get("fromMe") is True and data.get("fromOwner") is not True, + ) + source.is_one_to_one = raw_is_group is False + source.message_is_edit = bool( + data.get("isEdited") is True + or str(data.get("nativeType") or "").casefold() + in {"editedmessage", "protocolmessage:message_edit"} ) # Download media URLs to the local cache so agent tools @@ -1662,6 +1670,8 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv metadata["whatsapp_native_type"] = native_type if isinstance(native_metadata, dict) and native_metadata: metadata["whatsapp_native"] = native_metadata + if source.message_is_edit: + metadata["message_is_edit"] = True # The bridge sets ``fromOwner: true`` on inbound fromMe messages # that look owner-typed (linked-device send, not echoed from our # own /send). Surfaced under a platform-prefixed key so plugins diff --git a/tests/gateway/platforms/test_api_server_room_controls.py b/tests/gateway/platforms/test_api_server_room_controls.py new file mode 100644 index 0000000000000..c660498ea8d66 --- /dev/null +++ b/tests/gateway/platforms/test_api_server_room_controls.py @@ -0,0 +1,248 @@ +"""Scoped reciprocal API for remote Group Chat control.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from gateway import hosted_room_controls, hosted_rooms +from gateway.config import PlatformConfig +from gateway.platforms import api_server_room_controls +from gateway.platforms.api_server import APIServerAdapter + + +HOME = "install:home" + + +class FakeService: + def __init__(self, db_path: Path) -> None: + self.db_path = db_path + self.retried: list[str] = [] + + def status(self, room_id: str): + pending = [] if self.retried else [{"kind": "retry", "task_id": "task-1"}] + return { + "working": False, + "blocked": bool(pending), + "counts": {"deferred": len(pending)}, + "pending_actions": pending, + } + + def send_server_owned(self, *, room_id, event_id, payload, actor): + room = hosted_rooms.room_state(self.db_path, room_id=room_id) + return hosted_rooms.append_event( + self.db_path, + room_id=room_id, + event_id=event_id, + kind="message.user", + actor=actor, + payload=payload, + authority_gateway_id=str(room["authority_gateway_id"]), + authority_epoch=int(room["authority_epoch"]), + ) + + def stop_room(self, room_id, *, cancel_id): + room = hosted_rooms.room_state(self.db_path, room_id=room_id) + hosted_rooms.request_room_stop( + self.db_path, + room_id=room_id, + cancel_id=cancel_id, + expected_gateway_id=str(room["authority_gateway_id"]), + expected_epoch=int(room["authority_epoch"]), + ) + return 1 + + def retry_room_task(self, room_id, *, task_id): + assert room_id == "room-1" + self.retried.append(task_id) + return {"status": "queued"} + + +@pytest.fixture +def control_api(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + db = home / "state.db" + hosted_rooms.create_room( + db, + room_id="room-1", + name="Release room", + members=[ + {"member_id": "member-peer", "profile": "reviewer", "handle": "reviewer"}, + {"member_id": "local", "profile": "local", "handle": "local"}, + ], + authority_gateway_id=HOME, + ) + issued = hosted_room_controls.issue_home_control_token( + db, + room_id="room-1", + member_id="member-peer", + authority_gateway_id=HOME, + authority_epoch=1, + expires_at=10_000_000_000, + ) + service = FakeService(db) + monkeypatch.setattr( + "tui_gateway.methods_groups.get_hosted_room_service", + lambda: service, + ) + adapter = APIServerAdapter( + PlatformConfig(enabled=True, extra={"key": "sk-secret"}) + ) + app = web.Application() + for method, path, handler in api_server_room_controls._http_routes(adapter): + app.router.add_route(method, path, handler) + headers = { + "Authorization": f"HermesRoomControl {issued.control_token}", + "X-Hermes-Room-Member": "member-peer", + } + return adapter, app, service, headers + + +def test_api_server_registers_reciprocal_control_routes(control_api): + adapter, _app, _service, _headers = control_api + routes = {(method, path) for method, path, _handler in adapter._http_route_table()} + assert ("GET", "/v1/room-controls/{room_id}") in routes + assert ("POST", "/v1/room-controls/{room_id}") in routes + assert ("DELETE", "/v1/room-controls/{room_id}") in routes + + +@pytest.mark.asyncio +async def test_read_send_stop_and_retry_are_scoped_and_replay_safe(control_api): + _adapter, app, service, headers = control_api + async with TestClient(TestServer(app)) as client: + denied = await client.get("/v1/room-controls/room-1") + assert denied.status == 401 + + initial = await client.get("/v1/room-controls/room-1", headers=headers) + assert initial.status == 200 + initial_payload = await initial.json() + assert initial_payload["room"]["name"] == "Release room" + assert all( + set(member) <= {"member_id", "handle", "display_name"} + for member in initial_payload["room"]["members"] + ) + + send_body = { + "action": "send", + "command_id": "remote-send-1", + "actor_display_name": "Signal", + "text": "Review the release", + } + sent = await client.post( + "/v1/room-controls/room-1", + json=send_body, + headers=headers, + ) + replayed = await client.post( + "/v1/room-controls/room-1", + json=send_body, + headers=headers, + ) + assert sent.status == replayed.status == 200 + events = hosted_rooms.read_events( + service.db_path, + room_id="room-1", + since_seq=0, + limit=20, + )["events"] + user_events = [event for event in events if event["kind"] == "message.user"] + assert len(user_events) == 1 + assert user_events[0]["actor"] == { + "kind": "user", + "id": "peer:member-peer", + "display_name": "Signal", + } + + retried = await client.post( + "/v1/room-controls/room-1", + json={"action": "retry", "command_id": "remote-retry-1"}, + headers=headers, + ) + retry_replay = await client.post( + "/v1/room-controls/room-1", + json={"action": "retry", "command_id": "remote-retry-1"}, + headers=headers, + ) + assert retried.status == retry_replay.status == 200 + assert service.retried == [] + assert len( + hosted_room_controls.load_pending_control_retries( + service.db_path, + room_id="room-1", + ) + ) == 1 + + stopped = await client.post( + "/v1/room-controls/room-1", + json={"action": "stop", "command_id": "remote-stop-1"}, + headers=headers, + ) + assert stopped.status == 200 + stopped_events = hosted_rooms.read_events( + service.db_path, + room_id="room-1", + since_seq=0, + limit=20, + )["events"] + assert any(event["kind"] == "room.stop_requested" for event in stopped_events) + + revoked = await client.delete( + "/v1/room-controls/room-1", + headers=headers, + ) + assert revoked.status == 200 + revoke_replay = await client.delete( + "/v1/room-controls/room-1", + headers=headers, + ) + assert revoke_replay.status == 200 + denied_after_revoke = await client.get( + "/v1/room-controls/room-1", + headers=headers, + ) + assert denied_after_revoke.status == 401 + + +@pytest.mark.asyncio +async def test_control_token_is_member_and_room_scoped(control_api): + _adapter, app, _service, headers = control_api + async with TestClient(TestServer(app)) as client: + wrong_member = await client.get( + "/v1/room-controls/room-1", + headers={**headers, "X-Hermes-Room-Member": "local"}, + ) + wrong_room = await client.get( + "/v1/room-controls/other-room", + headers=headers, + ) + assert wrong_member.status == 401 + assert wrong_room.status == 401 + + +@pytest.mark.asyncio +async def test_retry_is_queued_for_the_process_that_owns_the_room_lease( + control_api, +): + _adapter, app, service, headers = control_api + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/v1/room-controls/room-1", + json={"action": "retry", "command_id": "remote-retry-worker"}, + headers=headers, + ) + assert response.status == 200 + payload = await response.json() + assert payload["queued"] is True + assert payload["retried"] == 1 + pending = hosted_room_controls.load_pending_control_retries( + service.db_path, + room_id="room-1", + ) + assert [(item.command_id, item.task_ids) for item in pending] == [ + ("remote-retry-worker", ("task-1",)) + ] diff --git a/tests/gateway/test_api_server_room_grants.py b/tests/gateway/test_api_server_room_grants.py index 0b91945f2e2ca..841c36b648a55 100644 --- a/tests/gateway/test_api_server_room_grants.py +++ b/tests/gateway/test_api_server_room_grants.py @@ -209,3 +209,65 @@ async def test_capability_handler_uses_legacy_claims_monkeypatch(monkeypatch): request, permission="status", ) + + +@pytest.mark.asyncio +async def test_grant_revoke_cleans_reciprocal_control_on_the_target(monkeypatch): + from gateway import hosted_room_control_client, hosted_room_peer, hosted_rooms + + adapter = api_server.APIServerAdapter.__new__(api_server.APIServerAdapter) + adapter._read_json_body = AsyncMock(return_value=({}, None)) + adapter._room_grant_token = MagicMock(return_value="grant-token") + adapter._room_grant_secret = MagicMock(return_value=b"s" * 32) + claims = { + "room_id": "room-1", + "member_id": "member-peer", + "target_profile": "reviewer", + "target_install_id": "install-target", + "expires_at": 200, + "status_expires_at": 300, + } + monkeypatch.setattr(hosted_room_peer, "decode_room_grant", lambda *_a, **_k: claims) + monkeypatch.setattr( + hosted_rooms, "local_authority_gateway_id", lambda: "install-target" + ) + revoke_grant = MagicMock() + monkeypatch.setattr(hosted_rooms, "revoke_room_grant_scope", revoke_grant) + revoke_control = MagicMock(return_value=1) + monkeypatch.setattr( + hosted_room_control_client, + "revoke_stored_peer_control", + revoke_control, + ) + profile_token = api_server._api_request_profile.set("reviewer") + try: + response = await room_grants._handle_room_member_grant_revoke( + adapter, + object(), + _openai_error=api_server._openai_error, + _api_request_profile=api_server._api_request_profile, + ) + finally: + api_server._api_request_profile.reset(profile_token) + + assert response.status == 200 + revoke_grant.assert_called_once() + revoke_control.assert_called_once_with( + hosted_rooms.default_db_path(), + room_id="room-1", + member_id="member-peer", + ) + + revoke_control.side_effect = RuntimeError("home unreachable") + profile_token = api_server._api_request_profile.set("reviewer") + try: + retryable = await room_grants._handle_room_member_grant_revoke( + adapter, + object(), + _openai_error=api_server._openai_error, + _api_request_profile=api_server._api_request_profile, + ) + finally: + api_server._api_request_profile.reset(profile_token) + assert retryable.status == 503 + assert json.loads(retryable.text)["error"]["code"] == "room_control_cleanup_pending" diff --git a/tests/gateway/test_api_server_runs_extraction.py b/tests/gateway/test_api_server_runs_extraction.py index 2709321e19a31..b1052914c2640 100644 --- a/tests/gateway/test_api_server_runs_extraction.py +++ b/tests/gateway/test_api_server_runs_extraction.py @@ -169,6 +169,9 @@ def test_roomlink_and_run_route_tuples_are_shard_owned(): ("GET", "/v1/room-members/capabilities"), ("POST", "/v1/room-members/grants/refresh"), ("POST", "/v1/room-members/grants/revoke"), + ("GET", "/v1/room-controls/{room_id}"), + ("POST", "/v1/room-controls/{room_id}"), + ("DELETE", "/v1/room-controls/{room_id}"), ] assert [(method, path) for method, path, _ in run_routes] == [ ("POST", "/v1/runs"), @@ -178,5 +181,6 @@ def test_roomlink_and_run_route_tuples_are_shard_owned(): ("POST", "/v1/runs/{run_id}/steer"), ("POST", "/v1/runs/{run_id}/stop"), ] - assert all(handler.__self__ is adapter for _, _, handler in room_routes) + assert all(handler.__self__ is adapter for _, _, handler in room_routes[:4]) + assert all(callable(handler) for _, _, handler in room_routes[4:]) assert all(handler.__self__ is adapter for _, _, handler in run_routes) diff --git a/tests/gateway/test_desktop_room_mailbox.py b/tests/gateway/test_desktop_room_mailbox.py new file mode 100644 index 0000000000000..f37e687569dba --- /dev/null +++ b/tests/gateway/test_desktop_room_mailbox.py @@ -0,0 +1,818 @@ +from __future__ import annotations + +import hashlib + +import pytest + +from gateway import desktop_room_mailbox as mailbox + + +class Clock: + def __init__(self, value: float = 1000.0) -> None: + self.value = value + + def __call__(self) -> float: + return self.value + + +def authorities(*room_ids: str, token: str = "authority:one") -> list[dict]: + return [ + {"room_id": room_id, "authority_token": token} + for room_id in room_ids + ] + + +def authority_commitment(token: str = "authority:one") -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def test_enqueue_is_idempotent_and_rejects_key_reuse(tmp_path): + db = tmp_path / "state.db" + first = mailbox.enqueue_command( + db, + command_id="messaging:abc", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + replay = mailbox.enqueue_command( + db, + command_id="messaging:abc", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + + assert first["state"] == "pending" + assert replay["idempotent"] is True + with pytest.raises(mailbox.DesktopRoomMailboxError, match="different room work"): + mailbox.enqueue_command( + db, + command_id="messaging:abc", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "changed"}, + ) + + +def test_enqueue_moves_the_cross_process_pending_signal(tmp_path): + db = tmp_path / "desktop_room_mailbox.db" + signal = mailbox.pending_signal_path(db) + + mailbox.enqueue_command( + db, + command_id="messaging:first", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + first = signal.read_text(encoding="ascii") + mailbox.enqueue_command( + db, + command_id="messaging:second", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "again"}, + ) + + assert signal.read_text(encoding="ascii") != first + assert signal.stat().st_mode & 0o777 == 0o600 + + +def test_signal_failure_does_not_change_a_durable_enqueue(tmp_path, monkeypatch): + db = tmp_path / "desktop_room_mailbox.db" + mailbox.enqueue_command( + db, + command_id="messaging:seed", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "seed"}, + ) + monkeypatch.setattr( + type(mailbox.pending_signal_path(db)), + "write_text", + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("read only")), + ) + + queued = mailbox.enqueue_command( + db, + command_id="messaging:still-durable", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + + assert queued["state"] == "pending" + + +def test_one_desktop_claims_and_presence_is_room_scoped(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:one", + room_id="name:Classic room", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("name:Classic room"), + clock=clock, + ) + duplicate = mailbox.claim_commands( + db, + consumer_id="desktop:second", + room_authorities=authorities("name:Classic room", token="authority:two"), + clock=clock, + ) + + assert [item["command_id"] for item in claimed] == ["messaging:one"] + assert duplicate == [] + assert mailbox.room_available(db, "name:Classic room", clock=clock) is True + assert mailbox.room_available(db, "another-room", clock=clock) is False + + +def test_expired_claim_is_recovered_by_the_registered_desktop(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:retry", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + first = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + claim_ttl=10, + presence_ttl=10, + clock=clock, + ) + clock.value += 11 + second = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + + assert first[0]["attempts"] == 1 + assert second[0]["attempts"] == 2 + with pytest.raises(mailbox.DesktopRoomMailboxError, match="no longer owned"): + mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id="messaging:retry", + lease_token=first[0]["lease_token"], + success=True, + result={"thread_id": "old"}, + clock=clock, + ) + + +def test_completion_ack_retry_is_idempotent(tmp_path): + db = tmp_path / "state.db" + mailbox.enqueue_command( + db, + command_id="messaging:complete", + room_id="room-1", + authority_hash=authority_commitment(), + action="stop", + payload={"target_command_id": "messaging:send-1"}, + ) + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + ) + first = mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id="messaging:complete", + lease_token=claimed[0]["lease_token"], + success=True, + result={"stopped": True}, + ) + replay = mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id="messaging:complete", + lease_token=claimed[0]["lease_token"], + success=True, + result={"stopped": True}, + ) + + assert first["state"] == "completed" + assert replay["idempotent"] is True + + +def test_explicit_retry_requeues_one_failed_command_idempotently(tmp_path): + db = tmp_path / "state.db" + mailbox.enqueue_command( + db, + command_id="messaging:failed", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + )[0] + failed = mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id=claimed["command_id"], + lease_token=claimed["lease_token"], + success=False, + result={"error": "temporarily unavailable"}, + ) + + assert failed["state"] == "failed" + retried = mailbox.retry_failed_command(db, room_id="room-1") + replay = mailbox.retry_failed_command( + db, + room_id="room-1", + command_id=claimed["command_id"], + ) + assert retried["state"] == "pending" + assert replay["idempotent"] is True + reclaimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + ) + assert reclaimed[0]["command_id"] == "messaging:failed" + + +def test_reclaim_rotates_token_and_fences_a_stale_attempt(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:fenced", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + first = mailbox.claim_commands( + db, + consumer_id="desktop:same-install", + room_authorities=authorities("room-1"), + claim_ttl=5, + clock=clock, + )[0] + clock.value += 6 + second = mailbox.claim_commands( + db, + consumer_id="desktop:same-install", + room_authorities=authorities("room-1"), + claim_ttl=5, + clock=clock, + )[0] + + assert first["lease_token"] != second["lease_token"] + with pytest.raises(mailbox.DesktopRoomMailboxError, match="no longer owned"): + mailbox.complete_command( + db, + consumer_id="desktop:same-install", + command_id="messaging:fenced", + lease_token=first["lease_token"], + success=True, + result={"thread_id": "old"}, + clock=clock, + ) + + +def test_live_claim_can_be_renewed(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:renew", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + claim_ttl=10, + clock=clock, + )[0] + clock.value += 8 + renewed = mailbox.renew_command( + db, + consumer_id="desktop:first", + command_id="messaging:renew", + lease_token=claimed["lease_token"], + claim_ttl=10, + clock=clock, + ) + clock.value += 5 + + assert renewed["lease_token"] == claimed["lease_token"] + assert mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id="messaging:renew", + lease_token=claimed["lease_token"], + success=True, + result={"thread_id": "thread-1"}, + clock=clock, + )["state"] == "completed" + + +def test_presence_expires_without_deleting_pending_work(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:later", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "later"}, + clock=clock, + ) + mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + presence_ttl=5, + claim_ttl=5, + clock=clock, + ) + clock.value += 6 + + assert mailbox.room_available(db, "room-1", clock=clock) is False + reclaimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + assert reclaimed[0]["command_id"] == "messaging:later" + + +def test_authority_token_fences_a_cold_desktop_after_owner_expiry(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:fenced-room", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + claim_ttl=5, + presence_ttl=5, + clock=clock, + ) + clock.value += 6 + + assert mailbox.claim_commands( + db, + consumer_id="desktop:cold", + room_authorities=authorities("room-1", token="authority:wrong"), + clock=clock, + ) == [] + reclaimed = mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + clock=clock, + ) + assert reclaimed[0]["command_id"] == "messaging:fenced-room" + + +def test_claim_cannot_establish_room_authority(tmp_path): + db = tmp_path / "state.db" + mailbox.enqueue_command( + db, + command_id="messaging:unregistered", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + ) + + assert mailbox.claim_commands( + db, + consumer_id="desktop:untrusted", + room_authorities=authorities("room-1", token="authority:guessed"), + ) == [] + + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + ) + assert [item["command_id"] for item in claimed] == ["messaging:unregistered"] + + +def test_projected_authority_commitment_is_idempotent_and_fenced(tmp_path): + db = tmp_path / "state.db" + assert mailbox.register_projected_authorities( + db, + [{"room_id": "room-1", "authority_hash": authority_commitment()}], + ) == ["room-1"] + assert mailbox.register_projected_authorities( + db, + [{"room_id": "room-1", "authority_hash": authority_commitment()}], + ) == ["room-1"] + + assert mailbox.register_projected_authorities( + db, + [{ + "room_id": "room-1", + "authority_hash": authority_commitment("authority:other"), + }], + ) == [] + + assert mailbox.register_projected_authorities( + db, + [ + { + "room_id": "room-1", + "authority_hash": authority_commitment("authority:other"), + }, + {"room_id": "room-2", "authority_hash": authority_commitment()}, + ], + ) == ["room-2"] + + with pytest.raises(mailbox.DesktopRoomMailboxError, match="commitment"): + mailbox.enqueue_command( + db, + command_id="messaging:conflict", + room_id="room-1", + authority_hash=authority_commitment("authority:other"), + action="send", + payload={"message": "must not replace owner"}, + ) + + +def test_stop_supersedes_an_earlier_send_before_it_is_claimed(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + for action in ("send", "stop"): + mailbox.enqueue_command( + db, + command_id=f"messaging:{action}", + room_id="room-1", + authority_hash=authority_commitment(), + action=action, + payload=( + {"message": "hello"} + if action == "send" + else {"target_command_id": "messaging:send"} + ), + clock=clock, + ) + + stopped = mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + actions=["stop"], + clock=clock, + ) + assert [item["action"] for item in stopped] == ["stop"] + assert stopped[0]["target_command_state"] == "failed" + assert stopped[0]["target_result_code"] == "superseded_by_stop" + sends = mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + actions=["send"], + clock=clock, + ) + assert sends == [] + + +def test_renew_keeps_room_ownership_alive_for_long_turn(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:long", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "hello"}, + clock=clock, + ) + command = mailbox.claim_commands( + db, + consumer_id="desktop:owner", + room_authorities=authorities("room-1"), + claim_ttl=45, + presence_ttl=90, + clock=clock, + )[0] + + for _ in range(8): + clock.value += 30 + mailbox.renew_command( + db, + consumer_id="desktop:owner", + command_id=command["command_id"], + lease_token=command["lease_token"], + claim_ttl=45, + presence_ttl=90, + clock=clock, + ) + + assert mailbox.room_available(db, "room-1", clock=clock) is True + assert mailbox.claim_commands( + db, + consumer_id="desktop:other", + room_authorities=authorities("room-1"), + actions=["stop"], + clock=clock, + ) == [] + + +def test_default_presence_overlaps_the_minute_desktop_backstop(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.register_projected_authorities( + db, + [{"room_id": "room-1", "authority_hash": authority_commitment()}], + clock=clock, + ) + mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + + clock.value += 61 + assert mailbox.room_available(db, "room-1", clock=clock) is True + clock.value += 30 + assert mailbox.room_available(db, "room-1", clock=clock) is False + + +def test_latest_command_state_is_scoped_per_room(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:first", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "first"}, + clock=clock, + ) + clock.value += 1 + mailbox.enqueue_command( + db, + command_id="messaging:second", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "second"}, + clock=clock, + ) + mailbox.enqueue_command( + db, + command_id="messaging:other", + room_id="room-2", + authority_hash=authority_commitment(), + action="stop", + payload={"target_command_id": "messaging:send-1"}, + clock=clock, + ) + + states = mailbox.latest_command_states(db, ["room-1", "room-2"]) + + assert states["room-1"]["command_id"] == "messaging:second" + assert states["room-2"]["command_id"] == "messaging:other" + + +def test_paged_claims_preserve_presence_for_every_owned_room(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + rooms = [f"room-{index}" for index in range(260)] + + for index in range(0, len(rooms), mailbox.MAX_ROOM_IDS): + batch = rooms[index : index + mailbox.MAX_ROOM_IDS] + mailbox.register_projected_authorities( + db, + [ + {"room_id": room_id, "authority_hash": authority_commitment()} + for room_id in batch + ], + clock=clock, + ) + mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities(*batch), + clock=clock, + ) + + assert mailbox.available_room_ids(db, rooms, clock=clock) == set(rooms) + + +def test_presence_refresh_allows_secret_proven_takeover_after_expiry(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.register_projected_authorities( + db, + [{"room_id": "room-1", "authority_hash": authority_commitment()}], + clock=clock, + ) + + assert mailbox.refresh_presence( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + presence_ttl=5, + clock=clock, + ) == ["room-1"] + assert mailbox.refresh_presence( + db, + consumer_id="desktop:second", + room_authorities=authorities("room-1"), + presence_ttl=5, + clock=clock, + ) == [] + + clock.value += 6 + assert mailbox.refresh_presence( + db, + consumer_id="desktop:second", + room_authorities=authorities("room-1", token="authority:wrong"), + clock=clock, + ) == [] + assert mailbox.refresh_presence( + db, + consumer_id="desktop:second", + room_authorities=authorities("room-1"), + clock=clock, + ) == ["room-1"] + + +def test_pending_command_expires_instead_of_running_days_later(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + mailbox.enqueue_command( + db, + command_id="messaging:stale", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "old"}, + clock=clock, + ) + + clock.value += mailbox.PENDING_TTL_SECONDS + 1 + assert mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) == [] + state = mailbox.latest_command_states(db, ["room-1"])["room-1"] + assert state["state"] == "failed" + assert state["result"]["code"] == "command_expired" + + +def test_retry_requeues_all_bounded_expired_commands_oldest_first(tmp_path): + db = tmp_path / "state.db" + clock = Clock() + for index in range(2): + mailbox.enqueue_command( + db, + command_id=f"messaging:stale-{index}", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": str(index)}, + clock=clock, + ) + clock.value += 1 + + clock.value += mailbox.PENDING_TTL_SECONDS + 1 + assert mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) == [] + + frozen = mailbox.retryable_command_ids(db, room_id="room-1") + assert frozen == ("messaging:stale-0", "messaging:stale-1") + retried = mailbox.retry_failed_commands( + db, + room_id="room-1", + command_ids=frozen, + clock=clock, + ) + assert [command["command_id"] for command in retried] == list(frozen) + + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + assert [command["payload"]["message"] for command in claimed] == ["0", "1"] + + +def test_pending_queue_is_bounded_per_group_chat(tmp_path, monkeypatch): + db = tmp_path / "state.db" + monkeypatch.setattr(mailbox, "MAX_PENDING_COMMANDS_PER_ROOM", 2) + for index in range(2): + mailbox.enqueue_command( + db, + command_id=f"messaging:{index}", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": str(index)}, + ) + + with pytest.raises(mailbox.DesktopRoomMailboxError, match="too many commands"): + mailbox.enqueue_command( + db, + command_id="messaging:overflow", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": "overflow"}, + ) + + +def test_stop_supersedes_pending_sends_and_is_claimed_first(tmp_path): + db = tmp_path / "state.db" + for index in range(2): + mailbox.enqueue_command( + db, + command_id=f"messaging:send-{index}", + room_id="room-1", + authority_hash=authority_commitment(), + action="send", + payload={"message": str(index)}, + ) + mailbox.enqueue_command( + db, + command_id="messaging:stop", + room_id="room-1", + authority_hash=authority_commitment(), + action="stop", + payload={"target_command_id": "messaging:send-1"}, + ) + + with mailbox._transaction(db) as conn: + send_states = { + str(row["command_id"]): str(row["state"]) + for row in conn.execute( + "SELECT command_id, state FROM desktop_room_commands WHERE action='send'" + ) + } + claimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + ) + + assert send_states == { + "messaging:send-0": "failed", + "messaging:send-1": "failed", + } + assert [command["command_id"] for command in claimed] == ["messaging:stop"] + assert claimed[0]["target_command_state"] == "failed" + assert claimed[0]["target_result_code"] == "superseded_by_stop" diff --git a/tests/gateway/test_discord_component_auth.py b/tests/gateway/test_discord_component_auth.py index fd6838a941625..2d647bfdba529 100644 --- a/tests/gateway/test_discord_component_auth.py +++ b/tests/gateway/test_discord_component_auth.py @@ -13,6 +13,7 @@ """ from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -20,6 +21,8 @@ # importing the production module. from plugins.platforms.discord.adapter import ( # noqa: E402 ClarifyChoiceView, + ChoicePickerView, + DiscordAdapter, ExecApprovalView, ModelPickerView, SlashConfirmView, @@ -145,6 +148,68 @@ def test_clarify_choice_view_accepts_role_allowlist(): assert view._check_auth(_interaction(99999, role_ids=[7])) is False +def test_choice_picker_is_bound_to_the_exact_requester(): + async def _noop(*_args): + return "" + + view = ChoicePickerView( + choices=[{"value": "1", "label": "Room"}], + on_choice_selected=_noop, + allowed_user_ids={"11111", "22222"}, + requester_user_id="11111", + ) + + assert view._check_auth(_interaction(11111)) is True + assert view._check_auth(_interaction(22222)) is False + + +@pytest.mark.asyncio +async def test_navigation_picker_uses_neutral_discord_chrome(monkeypatch): + from plugins.platforms.discord import adapter as discord_adapter + + class _Embed: + def __init__(self, *, title=None, description=None, color=None): + self.title = title + self.description = description + self.color = color + + monkeypatch.setattr(discord_adapter, "DISCORD_AVAILABLE", True) + monkeypatch.setattr(discord_adapter.discord, "Embed", _Embed) + monkeypatch.setattr(discord_adapter.discord.Color, "blue", lambda: "blue") + channel = SimpleNamespace( + send=AsyncMock(return_value=SimpleNamespace(id=7)), + ) + client = SimpleNamespace( + get_channel=lambda _channel_id: channel, + fetch_channel=AsyncMock(return_value=channel), + ) + adapter = object.__new__(DiscordAdapter) + adapter._client = client + adapter._allowed_user_ids = {"11111"} + adapter._allowed_role_ids = set() + + result = await adapter.send_choice_picker( + chat_id="123", + title="👥 Group Chats\nChoose a recent Group Chat.", + choices=[ + { + "value": "room-token", + "label": "🟢 Release room", + "full_width": True, + } + ], + session_key="session-1", + on_choice_selected=AsyncMock(), + metadata={"requester_user_id": "11111"}, + ) + + assert result.success is True + sent = channel.send.await_args.kwargs + assert sent["embed"].title == "👥 Group Chats" + assert sent["view"].navigation is True + assert sent["view"].requester_user_id == "11111" + + # --------------------------------------------------------------------------- # Empty allowlists across views: fail closed unless allow-all is explicit. # --------------------------------------------------------------------------- @@ -277,4 +342,3 @@ def test_other_views_not_admin_gated(): session_key="s", confirm_id="c", allowed_user_ids={"11111"} ) assert sc._check_auth(_interaction(11111)) is True - diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index e3e5a39ff57ce..eca856ea3c97a 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -474,6 +474,13 @@ async def _empty(): return _empty() +class _FakeDMChannel(_discord_mod.DMChannel): + def __init__(self, channel_id=101): + self.id = channel_id + self.name = "Direct message" + self.topic = None + + class _FakeThreadChannel(_discord_mod.Thread): """isinstance(ch, discord.Thread) → True.""" @@ -493,6 +500,38 @@ async def _empty(): return _empty() +def test_discord_slash_interaction_supplies_room_control_idempotency(adapter): + from gateway.hosted_room_messaging import messaging_event_id + + channel = _FakeTextChannel(channel_id=123) + interaction = SimpleNamespace( + id=987654321, + channel=channel, + channel_id=channel.id, + user=SimpleNamespace(id=42, display_name="Jezza"), + ) + event = adapter._build_slash_event( + interaction, + "/group 1 send hello", + ) + assert messaging_event_id(event) == messaging_event_id(event) + assert event.source.is_one_to_one is False + + +def test_discord_dm_slash_marks_verified_one_to_one(adapter): + channel = _FakeDMChannel() + interaction = SimpleNamespace( + id=987654322, + channel=channel, + channel_id=channel.id, + user=SimpleNamespace(id=42, display_name="Jezza"), + ) + + event = adapter._build_slash_event(interaction, "/group") + + assert event.source.is_one_to_one is True + + def _fake_message(channel, *, content="Hello", author_id=42, display_name="Jezza"): return SimpleNamespace( author=SimpleNamespace(id=author_id, display_name=display_name, bot=False), @@ -600,5 +639,3 @@ def test_register_skill_command_payload_fits_discord_8kb_limit(adapter): f"Flat /skill command payload is ~{len(payload)} bytes — the whole " f"point of this design is that it stays small regardless of skill count" ) - - diff --git a/tests/gateway/test_group_chat_matrix_adapter.py b/tests/gateway/test_group_chat_matrix_adapter.py new file mode 100644 index 0000000000000..e9d0b49edc96c --- /dev/null +++ b/tests/gateway/test_group_chat_matrix_adapter.py @@ -0,0 +1,84 @@ +"""Matrix ingress guarantees used by Group Chat messaging controls.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.hosted_room_messaging import messaging_event_id +from tests.gateway.test_matrix import _make_adapter + + +@pytest.mark.asyncio +async def test_named_two_member_dm_stamps_one_to_one_proof(): + adapter = _make_adapter() + adapter._joined_rooms = {"!named_dm:ex.org"} + adapter._dm_rooms = {"!named_dm:ex.org": True} + adapter._client = MagicMock() + adapter._client.get_state_event = AsyncMock( + side_effect=lambda _room_id, event_type: {"name": "Alice & Bot"} + if event_type == "m.room.name" + else (_ for _ in ()).throw(Exception("no alias")) + ) + adapter._client.state_store = MagicMock() + adapter._client.state_store.get_members = AsyncMock( + return_value=["@bot:ex.org", "@alice:ex.org"] + ) + adapter._get_display_name = AsyncMock(return_value="Alice") + adapter._background_read_receipt = MagicMock() + + identity = await adapter._resolve_room_identity("!named_dm:ex.org") + context = await adapter._resolve_message_context( + "!named_dm:ex.org", + "@alice:ex.org", + "$event", + "hello", + {"body": "hello"}, + {}, + ) + + assert identity.chat_type == "dm" + assert identity.joined_member_count == 2 + assert context is not None + assert context[-1].is_one_to_one is True + + +@pytest.mark.asyncio +async def test_room_control_normalizes_and_keeps_matrix_event_id(): + adapter = _make_adapter() + adapter._is_dm_room = AsyncMock(return_value=True) + adapter._require_mention = True + adapter._free_rooms = set() + captured = [] + + async def capture(event): + captured.append(event) + + adapter.handle_message = capture + await adapter._handle_text_message( + room_id="!room:example.org", + sender="@alice:example.org", + event_id="$matrix-command-test", + event_ts=0.0, + source_content={"msgtype": "m.text", "body": "!group 1 send hello"}, + relates_to={}, + ) + + assert len(captured) == 1 + assert captured[0].text == "/group 1 send hello" + assert messaging_event_id(captured[0]) == messaging_event_id(captured[0]) + + +def test_picker_literal_at_signs_do_not_emit_matrix_mentions(): + adapter = _make_adapter() + adapter._allow_room_mentions = True + + content = adapter._build_text_message_content( + "🟢 1. @room\n" + "🤖 Operator · alice:example.org\n" + "💬 **@alice:example.org**\n" + "• **Operator (`@alice:example.org`):** @room status" + ) + + assert "m.mentions" not in content diff --git a/tests/gateway/test_group_chat_slack_adapter.py b/tests/gateway/test_group_chat_slack_adapter.py new file mode 100644 index 0000000000000..d7cf3b513189b --- /dev/null +++ b/tests/gateway/test_group_chat_slack_adapter.py @@ -0,0 +1,112 @@ +"""Slack ingress guarantees used by Group Chat messaging controls.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from gateway.hosted_room_messaging import messaging_event_id +from tests.gateway.test_slack import _redirect_cache, adapter + + +@pytest.mark.asyncio +async def test_users_info_bot_classification_reaches_session_source(adapter): + adapter.config.extra["allow_bots"] = "all" + adapter._app.client.users_info = AsyncMock( + return_value={ + "user": { + "is_bot": True, + "profile": {"display_name": "Peer Bot"}, + "real_name": "Peer Bot", + } + } + ) + event = { + "type": "message", + "user": "U_PEER_BOT", + "channel": "D_SHARED", + "channel_type": "im", + "text": "hello from a peer bot", + "ts": "1770000000.000001", + } + + await adapter._handle_slack_message(event) + + delivered = adapter.handle_message.await_args.args[0] + assert delivered.source.is_bot is True + assert delivered.source.is_one_to_one is True + + +@pytest.mark.asyncio +async def test_channel_slash_command_uses_group_session_semantics(adapter): + await adapter._handle_slash_command( + { + "text": "hello", + "user_id": "U123", + "channel_id": "C123", + "team_id": "T123", + } + ) + + event = adapter.handle_message.await_args.args[0] + assert event.source.chat_type == "group" + assert event.source.chat_id == "C123" + assert event.source.user_id == "U123" + assert event.source.scope_id == "T123" + assert event.source.is_one_to_one is False + + +@pytest.mark.asyncio +async def test_message_edit_stamps_untrusted_command_provenance(adapter): + await adapter._handle_slack_message( + { + "text": "whats the rapchat summary for last 12 hours", + "user": "U_USER", + "channel": "C123", + "channel_type": "mpim", + "team": "T123", + "ts": "1234567890.000001", + } + ) + adapter.handle_message.assert_not_called() + + await adapter._handle_slack_message( + { + "subtype": "message_changed", + "channel": "C123", + "channel_type": "mpim", + "team": "T123", + "ts": "1234567890.000001", + "message": { + "text": "<@U_BOT> whats the rapchat summary for last 12 hours", + "user": "U_USER", + "channel": "C123", + "ts": "1234567890.000001", + "edited": {"user": "U_USER", "ts": "1234567899.000001"}, + }, + } + ) + + event = adapter.handle_message.call_args[0][0] + assert event.source.is_one_to_one is False + assert event.source.message_is_edit is True + assert event.metadata["message_is_edit"] is True + + +@pytest.mark.asyncio +async def test_room_control_keeps_slack_trigger_for_idempotency(adapter): + await adapter._handle_slash_command( + { + "command": "/hermes", + "text": "group 1 send hello", + "trigger_id": "trigger-room-1", + "user_id": "U1", + "channel_id": "C1", + "team_id": "T1", + } + ) + + event = adapter.handle_message.call_args[0][0] + assert event.text == "/group 1 send hello" + assert messaging_event_id(event) == messaging_event_id(event) diff --git a/tests/gateway/test_hosted_room_control_client.py b/tests/gateway/test_hosted_room_control_client.py new file mode 100644 index 0000000000000..b3dc823260702 --- /dev/null +++ b/tests/gateway/test_hosted_room_control_client.py @@ -0,0 +1,161 @@ +"""Credential-safe reciprocal Group Chat control client.""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from gateway import hosted_room_controls +from gateway.hosted_room_control_client import ( + RoomControlHTTPClient, + revoke_stored_peer_control, +) +from gateway.hosted_room_controls import StoredPeerRoomControl + + +class ControlHandler(BaseHTTPRequestHandler): + requests = [] + + def _reply(self, payload): + data = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + type(self).requests.append( + ("GET", self.path, dict(self.headers), None) + ) + self._reply({"room": {"room_id": "room-1"}, "events": []}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length) or b"{}") + type(self).requests.append( + ("POST", self.path, dict(self.headers), body) + ) + self._reply({"action": body["action"], "summary": {"events": []}}) + + def do_DELETE(self): + type(self).requests.append( + ("DELETE", self.path, dict(self.headers), None) + ) + self._reply({"revoked": 1}) + + def log_message(self, *_args): + pass + + +@pytest.fixture +def control_server(): + ControlHandler.requests = [] + server = HTTPServer(("127.0.0.1", 0), ControlHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join(timeout=5) + + +def _link(url: str) -> StoredPeerRoomControl: + return StoredPeerRoomControl( + room_id="room-1", + member_id="member-peer", + home_url=url, + transport_security="loopback", + authority_gateway_id="install:home", + authority_epoch=1, + room_name="Planning", + member_count=2, + control_token="A" * 43, + status="active", + created_at=1, + updated_at=1, + expires_at=10_000_000_000, + ) + + +def test_summary_and_mutation_keep_the_token_in_headers(control_server): + client = RoomControlHTTPClient(_link(control_server)) + + assert client.summary()["room"]["room_id"] == "room-1" + assert client.mutate( + action="send", + command_id="command-1", + text="hello", + actor_display_name="Signal", + )["action"] == "send" + client.revoke() + + assert [request[0] for request in ControlHandler.requests] == [ + "GET", + "POST", + "DELETE", + ] + for _method, path, headers, body in ControlHandler.requests: + assert path == "/v1/room-controls/room-1" + assert headers["Authorization"] == "HermesRoomControl " + "A" * 43 + assert headers["X-Hermes-Room-Member"] == "member-peer" + assert "A" * 43 not in repr(body) + + +def test_control_client_refuses_cross_origin_redirects(): + class RedirectHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(302) + self.send_header("Location", "http://127.0.0.1:9/stolen") + self.end_headers() + + def log_message(self, *_args): + pass + + server = HTTPServer(("127.0.0.1", 0), RedirectHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = RoomControlHTTPClient(_link(f"http://127.0.0.1:{server.server_port}")) + with pytest.raises(Exception): + client.summary() + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_stored_peer_revoke_contacts_home_before_erasing_bearer( + tmp_path, monkeypatch +): + db = tmp_path / "state.db" + saved = hosted_room_controls.save_peer_control_link( + db, + room_id="room-1", + member_id="member-peer", + room_name="Planning", + member_count=2, + home_url="https://home.example.test", + authority_gateway_id="install:home", + authority_epoch=1, + control_token="A" * 43, + expires_at=10_000_000_000, + now=20, + ) + revoked = [] + monkeypatch.setattr( + RoomControlHTTPClient, + "revoke", + lambda self: revoked.append(self.link.room_id), + ) + + assert revoke_stored_peer_control( + db, room_id="room-1", member_id="member-peer" + ) == 1 + assert revoked == [saved.link.room_id] + assert hosted_room_controls.load_peer_control_links( + db, include_inactive=True, now=30 + ).links == () diff --git a/tests/gateway/test_hosted_room_controls.py b/tests/gateway/test_hosted_room_controls.py new file mode 100644 index 0000000000000..3c40bc808375a --- /dev/null +++ b/tests/gateway/test_hosted_room_controls.py @@ -0,0 +1,637 @@ +"""Storage and credential tests for reciprocal hosted-room control.""" + +from __future__ import annotations + +import hmac +import json +import os +import secrets +import sqlite3 +import stat +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from gateway import hosted_room_controls as controls +from gateway import hosted_rooms + + +HOME = "install:gateway-a" + + +def _create_room(db, room_id="room-1", *, authority=HOME, epoch=1): + room = hosted_rooms.create_room( + db, + room_id=room_id, + name="Release room", + members=[ + { + "member_id": "member-1", + "profile": "reviewer", + "handle": "reviewer", + } + ], + authority_gateway_id=authority, + now=10, + ) + assert room["authority_epoch"] == epoch + return room + + +def _issue(db, *, room_id="room-1", member_id="member-1", now=20): + return controls.issue_home_control_token( + db, + room_id=room_id, + member_id=member_id, + authority_gateway_id=HOME, + authority_epoch=1, + expires_at=now + 600, + now=now, + ) + + +def _save(db, token, *, room_id="room-1", member_id="member-1", **overrides): + values = { + "home_url": "https://home.example.test", + "authority_gateway_id": HOME, + "authority_epoch": 1, + "room_name": "Planning", + "member_count": 2, + "expires_at": 620, + "now": 20, + **overrides, + } + return controls.save_peer_control_link( + db, + room_id=room_id, + member_id=member_id, + control_token=token, + **values, + ) + + +def test_home_stores_only_sha256_and_never_exposes_token(tmp_path): + db = tmp_path / "state.db" + _create_room(db) + issued = _issue(db) + + with sqlite3.connect(db) as conn: + row = conn.execute( + "SELECT token_hash, status FROM hosted_room_control_tokens" + ).fetchone() + assert isinstance(row[0], bytes) + assert len(row[0]) == 32 + assert row[0] != issued.control_token.encode() + assert row[1] == "active" + assert issued.control_token not in repr(issued) + assert issued.control_token not in repr(issued.as_status()) + assert issued.control_token.encode() not in db.read_bytes() + + +def test_home_invitation_replay_recovers_the_same_opaque_token( + tmp_path, monkeypatch +): + db = tmp_path / "state.db" + _create_room(db) + monkeypatch.setattr( + controls, + "gateway_room_grant_secret", + lambda: b"s" * 32, + ) + common = { + "room_id": "room-1", + "member_id": "member-1", + "authority_gateway_id": HOME, + "authority_epoch": 1, + "expires_at": controls.ROOM_LIFETIME_EXPIRES_AT, + "request_id": "desktop-room-1-member-1-v1", + } + + first = controls.issue_home_control_token(db, now=20, **common) + replay = controls.issue_home_control_token(db, now=30, **common) + + assert replay.control_token == first.control_token + with sqlite3.connect(db) as conn: + assert conn.execute( + "SELECT COUNT(*) FROM hosted_room_control_tokens" + ).fetchone()[0] == 1 + with pytest.raises(controls.HostedRoomControlConflictError): + controls.issue_home_control_token( + db, + now=40, + **{**common, "request_id": "different-request"}, + ) + + +def test_previous_control_schema_adds_request_id_before_use(tmp_path, monkeypatch): + db = tmp_path / "state.db" + _create_room(db) + with sqlite3.connect(db) as conn: + conn.execute( + """CREATE TABLE hosted_room_control_tokens ( + room_id TEXT NOT NULL, + member_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + token_hash BLOB NOT NULL, + status TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + expires_at REAL NOT NULL, + revoked_at REAL, + PRIMARY KEY ( + room_id, member_id, authority_gateway_id, authority_epoch + ) + )""" + ) + monkeypatch.setattr( + controls, + "gateway_room_grant_secret", + lambda: b"s" * 32, + ) + + controls.issue_home_control_token( + db, + room_id="room-1", + member_id="member-1", + authority_gateway_id=HOME, + authority_epoch=1, + expires_at=controls.ROOM_LIFETIME_EXPIRES_AT, + request_id="desktop-room-1-member-1-v1", + now=20, + ) + + with sqlite3.connect(db) as conn: + columns = { + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_control_tokens)") + } + request_id = conn.execute( + "SELECT request_id FROM hosted_room_control_tokens" + ).fetchone()[0] + assert "request_id" in columns + assert request_id == "desktop-room-1-member-1-v1" + + +def test_verify_is_exactly_room_member_authority_epoch_and_active_room_scoped(tmp_path): + db = tmp_path / "state.db" + _create_room(db) + _create_room(db, "room-2") + issued = _issue(db) + base = { + "room_id": "room-1", + "member_id": "member-1", + "authority_gateway_id": HOME, + "authority_epoch": 1, + "control_token": issued.control_token, + "now": 30, + } + + assert controls.verify_home_control_token(db, **base) + for changed in ( + {"room_id": "room-2"}, + {"member_id": "member-2"}, + {"authority_gateway_id": "install:gateway-b"}, + {"authority_epoch": 2}, + {"control_token": "A" * 43}, + ): + assert not controls.verify_home_control_token(db, **{**base, **changed}) + + hosted_rooms.disband_room( + db, + room_id="room-1", + expected_gateway_id=HOME, + expected_epoch=1, + now=40, + ) + assert not controls.verify_home_control_token(db, **{**base, "now": 50}) + + +def test_verify_always_uses_constant_time_digest_comparison(tmp_path, monkeypatch): + db = tmp_path / "state.db" + _create_room(db) + issued = _issue(db) + seen = [] + original = hmac.compare_digest + + def record(left, right): + seen.append((left, right)) + return original(left, right) + + monkeypatch.setattr(controls.hmac, "compare_digest", record) + assert ( + controls.verify_home_control_token( + db, + room_id="room-1", + member_id="missing-member", + authority_gateway_id=HOME, + authority_epoch=1, + control_token=issued.control_token, + now=30, + ) + is False + ) + assert len(seen) == 1 + assert all(isinstance(value, bytes) and len(value) == 32 for value in seen[0]) + + +def test_home_expiry_and_revocation_fail_closed_and_revoke_is_idempotent(tmp_path): + db = tmp_path / "state.db" + _create_room(db) + issued = _issue(db) + kwargs = { + "room_id": "room-1", + "member_id": "member-1", + "authority_gateway_id": HOME, + "authority_epoch": 1, + "control_token": issued.control_token, + } + + assert not controls.verify_home_control_token(db, **kwargs, now=620) + assert ( + controls.revoke_home_control_tokens( + db, room_id="room-1", member_id="member-1", now=100 + ) + == 1 + ) + assert ( + controls.revoke_home_control_tokens( + db, room_id="room-1", member_id="member-1", now=101 + ) + == 0 + ) + assert not controls.verify_home_control_token(db, **kwargs, now=110) + + +def test_malformed_home_row_fails_closed_without_skipping_digest_comparison( + tmp_path, monkeypatch +): + db = tmp_path / "state.db" + _create_room(db) + issued = _issue(db) + with sqlite3.connect(db) as conn: + conn.execute("UPDATE hosted_room_control_tokens SET expires_at='not-a-time'") + conn.commit() + compared = 0 + original = hmac.compare_digest + + def record(left, right): + nonlocal compared + compared += 1 + return original(left, right) + + monkeypatch.setattr(controls.hmac, "compare_digest", record) + assert not controls.verify_home_control_token( + db, + room_id="room-1", + member_id="member-1", + authority_gateway_id=HOME, + authority_epoch=1, + control_token=issued.control_token, + now=30, + ) + assert compared == 1 + + +def test_revoked_or_expired_scope_can_issue_a_new_opaque_token(tmp_path): + db = tmp_path / "state.db" + _create_room(db) + first = _issue(db) + controls.revoke_home_control_tokens(db, room_id="room-1", now=30) + second = _issue(db, now=40) + + assert first.control_token != second.control_token + common = { + "room_id": "room-1", + "member_id": "member-1", + "authority_gateway_id": HOME, + "authority_epoch": 1, + "now": 50, + } + assert not controls.verify_home_control_token( + db, control_token=first.control_token, **common + ) + assert controls.verify_home_control_token( + db, control_token=second.control_token, **common + ) + + +def test_peer_link_save_load_restart_and_private_repr(tmp_path): + home_db = tmp_path / "home.db" + peer_db = tmp_path / "peer.db" + _create_room(home_db) + issued = _issue(home_db) + + created = _save(peer_db, issued.control_token) + repeated = _save(peer_db, issued.control_token) + loaded = controls.load_peer_control_links(peer_db, now=30) + + assert created.idempotent is False + assert repeated.idempotent is True + assert loaded.links == (created.link,) + assert loaded.quarantined == 0 + assert loaded.truncated is False + assert issued.control_token not in repr(created) + assert issued.control_token not in repr(created.link.as_status()) + assert created.link.transport_security == "tls" + + +@pytest.mark.parametrize( + "change", + [ + {"home_url": "https://other.example.test"}, + {"authority_gateway_id": "install:gateway-b"}, + {"authority_epoch": 2}, + ], +) +def test_peer_link_conflicts_on_changed_authority_or_url(tmp_path, change): + home_db = tmp_path / "home.db" + peer_db = tmp_path / "peer.db" + _create_room(home_db) + issued = _issue(home_db) + _save(peer_db, issued.control_token) + + with pytest.raises( + controls.HostedRoomControlConflictError, match="stored authority" + ): + _save(peer_db, issued.control_token, **change) + + +def test_peer_link_conflicts_on_changed_token_without_leaking_it(tmp_path): + home_db = tmp_path / "home.db" + peer_db = tmp_path / "peer.db" + _create_room(home_db) + first = _issue(home_db) + controls.revoke_home_control_tokens(home_db, room_id="room-1", now=30) + second = _issue(home_db, now=40) + _save(peer_db, first.control_token) + + with pytest.raises(controls.HostedRoomControlConflictError) as error: + _save(peer_db, second.control_token) + assert first.control_token not in str(error.value) + assert second.control_token not in str(error.value) + + +def test_peer_link_requires_https_or_loopback(tmp_path): + strong = secrets.token_urlsafe(controls.TOKEN_BYTES) + + with pytest.raises(controls.HostedRoomControlError, match="endpoint"): + _save(tmp_path / "state.db", strong, home_url="http://peer.example.test") + saved = _save(tmp_path / "state.db", strong, home_url="http://127.0.0.1:8080") + assert saved.link.transport_security == "loopback" + + +def test_peer_control_link_requires_the_exact_live_roomlink_reservation(tmp_path): + db = tmp_path / "state.db" + claims = { + "room_id": "room-1", + "member_id": "member-1", + "target_profile": "reviewer", + "authority_gateway_id": HOME, + "authority_epoch": 1, + } + hosted_rooms.reserve_peer_room( + db, + claims=claims, + expires_at=100, + now=20, + ) + assert controls.peer_reservation_matches( + db, + **claims, + now=30, + ) + assert not controls.peer_reservation_matches( + db, + **{**claims, "member_id": "member-2"}, + now=30, + ) + assert not controls.peer_reservation_matches( + db, + **claims, + now=100, + ) + + +def test_peer_expiry_and_revocation_are_durable_and_idempotent(tmp_path): + db = tmp_path / "state.db" + token = secrets.token_urlsafe(controls.TOKEN_BYTES) + _save(db, token, expires_at=40) + + expired = controls.load_peer_control_links(db, now=40, include_inactive=True) + assert expired.links[0].status == "expired" + assert controls.load_peer_control_links(db, now=41).links == () + assert ( + controls.revoke_peer_control_links( + db, room_id="room-1", member_id="member-1", now=50 + ) + == 1 + ) + assert ( + controls.revoke_peer_control_links( + db, room_id="room-1", member_id="member-1", now=51 + ) + == 0 + ) + reloaded = controls.load_peer_control_links(db, now=60, include_inactive=True) + assert reloaded.links[0].status == "revoked" + + +def test_malformed_peer_row_is_quarantined_without_secret_in_diagnostics(tmp_path): + db = tmp_path / "state.db" + token = secrets.token_urlsafe(controls.TOKEN_BYTES) + _save(db, token) + with sqlite3.connect(db) as conn: + conn.execute( + """UPDATE hosted_room_peer_controls + SET home_url='http://public.example.test' + WHERE room_id='room-1'""" + ) + conn.commit() + + loaded = controls.load_peer_control_links(db, now=30) + assert loaded.links == () + assert loaded.quarantined == 1 + assert token not in repr(loaded) + with sqlite3.connect(db) as conn: + row = conn.execute( + """SELECT status, quarantine_reason + FROM hosted_room_peer_controls WHERE room_id='room-1'""" + ).fetchone() + assert row == ("quarantined", "invalid_stored_link") + repeated = controls.load_peer_control_links(db, now=31) + assert repeated.links == () + assert repeated.quarantined == 0 + + +def test_peer_load_is_bounded_and_reports_truncation(tmp_path): + db = tmp_path / "state.db" + token = secrets.token_urlsafe(controls.TOKEN_BYTES) + for index in range(3): + _save(db, token, room_id=f"room-{index}", member_id=f"member-{index}") + + loaded = controls.load_peer_control_links(db, limit=2, now=30) + assert len(loaded.links) == 2 + assert loaded.truncated is True + + +def test_remote_retry_plan_and_result_are_idempotent_and_conflict_safe(tmp_path): + db = tmp_path / "state.db" + first = controls.begin_control_retry( + db, + command_id="retry-1", + room_id="room-1", + member_id="member-1", + task_ids=["task-1", "task-2"], + now=20, + ) + replay = controls.begin_control_retry( + db, + command_id="retry-1", + room_id="room-1", + member_id="member-1", + task_ids=["task-1", "task-2"], + now=21, + ) + assert first.idempotent is False + assert replay.idempotent is True + assert replay.task_ids == ("task-1", "task-2") + pending = controls.load_pending_control_retries(db, room_id="room-1") + assert [(item.command_id, item.member_id, item.task_ids) for item in pending] == [ + ("retry-1", "member-1", ("task-1", "task-2")) + ] + + completed = controls.complete_control_retry( + db, + command_id="retry-1", + result={"retried": 2}, + now=22, + ) + assert completed == {"retried": 2} + assert controls.load_pending_control_retries(db, room_id="room-1") == () + final = controls.begin_control_retry( + db, + command_id="retry-1", + room_id="room-1", + member_id="member-1", + task_ids=["task-1", "task-2"], + now=23, + ) + assert final.result == {"retried": 2} + + with pytest.raises(controls.HostedRoomControlConflictError): + controls.begin_control_retry( + db, + command_id="retry-1", + room_id="room-1", + member_id="member-1", + task_ids=["task-3"], + now=24, + ) + + +@pytest.mark.parametrize( + "stored_task_ids", + ["not-json", '{"task-real":true}', '"task-real"'], +) +def test_pending_retry_loader_quarantines_malformed_rows( + tmp_path, + stored_task_ids, +): + db = tmp_path / "state.db" + controls.begin_control_retry( + db, + command_id="retry-invalid", + room_id="room-1", + member_id="member-1", + task_ids=["task-1"], + now=20, + ) + with sqlite3.connect(db) as conn: + conn.execute( + """UPDATE hosted_room_control_commands + SET task_ids_json=? + WHERE command_id='retry-invalid'""", + (stored_task_ids,), + ) + conn.commit() + + assert controls.load_pending_control_retries(db, room_id="room-1") == () + with sqlite3.connect(db) as conn: + row = conn.execute( + """SELECT state, result_json + FROM hosted_room_control_commands + WHERE command_id='retry-invalid'""" + ).fetchone() + assert row[0] == "completed" + assert json.loads(row[1]) == { + "action": "retry", + "error": "invalid_stored_plan", + "retried": 0, + } + + +def test_failed_retry_rotation_does_not_starve_newer_commands(tmp_path): + db = tmp_path / "state.db" + for index in range(9): + controls.begin_control_retry( + db, + command_id=f"retry-{index}", + room_id="room-1", + member_id="member-1", + task_ids=[f"task-{index}"], + now=20 + index, + ) + first = controls.load_pending_control_retries(db, room_id="room-1", limit=8) + assert [item.command_id for item in first] == [f"retry-{index}" for index in range(8)] + for item in first: + assert controls.defer_control_retry( + db, + command_id=item.command_id, + now=100, + ) + + rotated = controls.load_pending_control_retries(db, room_id="room-1", limit=8) + assert rotated[0].command_id == "retry-8" + + +def test_concurrent_home_issuance_allows_only_one_token_per_scope(tmp_path): + db = tmp_path / "state.db" + _create_room(db) + + def issue(_index): + try: + return _issue(db).control_token + except controls.HostedRoomControlConflictError: + return None + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(issue, range(16))) + assert len([token for token in results if token is not None]) == 1 + + +def test_concurrent_peer_link_saves_do_not_lose_records(tmp_path): + db = tmp_path / "state.db" + token = secrets.token_urlsafe(controls.TOKEN_BYTES) + + def save(index): + return _save( + db, + token, + room_id=f"room-{index}", + member_id=f"member-{index}", + home_url=f"https://home-{index}.example.test", + ) + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(save, range(24))) + assert all(not result.idempotent for result in results) + assert len(controls.load_peer_control_links(db, now=30).links) == 24 + + +def test_state_database_permissions_are_owner_only_best_effort(tmp_path): + db = tmp_path / "state.db" + token = secrets.token_urlsafe(controls.TOKEN_BYTES) + _save(db, token) + if os.name == "posix": + assert stat.S_IMODE(db.stat().st_mode) == 0o600 diff --git a/tests/gateway/test_hosted_room_driver.py b/tests/gateway/test_hosted_room_driver.py index 65b6eaed7e60a..69bbc057efadb 100644 --- a/tests/gateway/test_hosted_room_driver.py +++ b/tests/gateway/test_hosted_room_driver.py @@ -514,6 +514,122 @@ def test_release_fails_closed_while_its_task_is_running(db): assert driver.release_lease(db, lease, clock=clock)["idempotent"] is False +def test_active_lease_revalidation_does_not_extend_expiry(db): + clock = FakeClock() + lease = _lease(db, clock, ttl=5) + + validated = driver.require_active_lease(db, lease, clock=clock) + + assert validated == lease + clock.advance(5) + with pytest.raises(driver.StaleLeaseError): + driver.require_active_lease(db, lease, clock=clock) + + +def test_start_task_atomically_honors_room_stop_fence(db): + clock = FakeClock() + identity = _identity() + lease = _lease(db, clock) + rooms.append_event( + db, + room_id="room-1", + event_id="user-before-start", + kind="message.user", + actor={"kind": "user", "id": "desktop"}, + payload={"text": "Start", "thread_id": "thread-1"}, + authority_gateway_id="gateway-a", + authority_epoch=1, + ) + _admit(db, identity, clock) + rooms.request_room_stop( + db, + room_id="room-1", + cancel_id="stop-before-start", + expected_gateway_id="gateway-a", + expected_epoch=1, + ) + + assert ( + driver.start_task( + db, + identity, + lease, + expected_cancel_generation=0, + clock=clock, + ) + is None + ) + assert driver.get_task(db, identity)["status"] == "cancelled" + + +@pytest.mark.parametrize("retry_state", ["deferred", "indeterminate"]) +def test_retry_atomically_honors_room_stop_fence(db, retry_state): + clock = FakeClock() + identity = _identity() + first = _lease(db, clock, ttl=5) + rooms.append_event( + db, + room_id="room-1", + event_id="user-before-retry", + kind="message.user", + actor={"kind": "user", "id": "desktop"}, + payload={"text": "Retry", "thread_id": "thread-1"}, + authority_gateway_id="gateway-a", + authority_epoch=1, + ) + _admit(db, identity, clock) + original = driver.start_task( + db, + identity, + first, + expected_cancel_generation=0, + clock=clock, + ) + assert original is not None + clock.advance(5) + recovered = _lease(db, clock, process="new-process", ttl=30) + driver.recover_room(db, recovered, clock=clock) + if retry_state == "deferred": + driver.defer_indeterminate_task( + db, + identity, + recovered, + expected_execution_generation=original.execution_generation, + expected_cancel_generation=original.cancel_generation, + reason="member_unavailable", + clock=clock, + ) + rooms.request_room_stop( + db, + room_id="room-1", + cancel_id="stop-before-retry", + expected_gateway_id="gateway-a", + expected_epoch=1, + ) + + if retry_state == "deferred": + retried = driver.requeue_deferred_task( + db, + identity, + recovered, + expected_execution_generation=original.execution_generation, + expected_cancel_generation=original.cancel_generation, + clock=clock, + ) + else: + retried = driver.requeue_indeterminate_task( + db, + identity, + recovered, + expected_execution_generation=original.execution_generation, + expected_cancel_generation=original.cancel_generation, + clock=clock, + ) + + assert retried["status"] == "cancelled" + assert retried["cancel_id"].startswith("stop-fence:") + + def test_restart_recovery_never_requeues_indeterminate_work(db): clock = FakeClock() running = _identity() @@ -617,6 +733,18 @@ def test_current_lease_can_commit_verified_indeterminate_receipt(db): result={"text": "recovered"}, clock=clock, ) + repeated = driver.resolve_indeterminate_task( + db, + running, + recovered, + expected_execution_generation=attempt.execution_generation, + expected_cancel_generation=attempt.cancel_generation, + settlement_id="recovered-receipt", + status="settled", + result={"text": "recovered"}, + clock=clock, + retry_id="retry-terminal", + ) next_attempt = driver.start_task( db, queued, @@ -626,6 +754,13 @@ def test_current_lease_can_commit_verified_indeterminate_receipt(db): ) assert settled["status"] == "settled" + assert repeated["idempotent"] is True + assert driver.retry_receipt_exists( + db, + room_id=running.room_id, + task_id=running.task_id, + retry_id="retry-terminal", + ) assert next_attempt.execution_generation == 1 @@ -662,11 +797,18 @@ def test_current_lease_can_commit_verified_indeterminate_cancellation(db): expected_cancel_generation=attempt.cancel_generation, cancel_id="remote-cancel:1", clock=clock, + retry_id="retry-cancelled", ) assert cancelled["status"] == "cancelled" assert cancelled["execution_generation"] == attempt.execution_generation assert repeated["idempotent"] is True + assert driver.retry_receipt_exists( + db, + room_id=running.room_id, + task_id=running.task_id, + retry_id="retry-cancelled", + ) def test_indeterminate_retry_is_explicit_and_advances_execution_generation(db): @@ -1069,6 +1211,45 @@ def test_pre_deferred_schema_is_migrated_without_losing_tasks(db): assert "'deferred'" in table_sql +def test_draft_retry_id_column_is_migrated_to_receipt_ledger(db): + clock = FakeClock() + identity = _identity() + _admit(db, identity, clock) + + with sqlite3.connect(db) as conn: + conn.execute("ALTER TABLE hosted_room_driver_tasks ADD COLUMN retry_id TEXT") + conn.execute( + """INSERT INTO hosted_room_retry_receipts( + retry_id, room_id, task_id, source_execution_generation, created_at + ) VALUES ('existing-retry', ?, ?, 0, 100)""", + (identity.room_id, identity.task_id), + ) + conn.execute( + "UPDATE hosted_room_driver_tasks SET retry_id='draft-retry'" + ) + conn.commit() + + assert driver.get_task(db, identity)["status"] == "queued" + with sqlite3.connect(db) as conn: + columns = { + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_driver_tasks)") + } + assert "retry_id" not in columns + assert driver.retry_receipt_exists( + db, + room_id=identity.room_id, + task_id=identity.task_id, + retry_id="draft-retry", + ) + assert driver.retry_receipt_exists( + db, + room_id=identity.room_id, + task_id=identity.task_id, + retry_id="existing-retry", + ) + + def test_first_schema_creation_is_safe_across_processes(db): with sqlite3.connect(db) as conn: conn.execute("DROP TABLE IF EXISTS hosted_room_driver_tasks") diff --git a/tests/gateway/test_hosted_room_messaging.py b/tests/gateway/test_hosted_room_messaging.py new file mode 100644 index 0000000000000..3768729c4bd59 --- /dev/null +++ b/tests/gateway/test_hosted_room_messaging.py @@ -0,0 +1,1740 @@ +"""Messaging controls for gateway-hosted Group Chats.""" + +from __future__ import annotations + +import hashlib +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway import hosted_room_controls, hosted_room_driver, hosted_room_messaging, hosted_rooms +from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig +from gateway.hosted_room_messaging import ( + MessagingRoomBackend, + RoomControlError, + format_room_bot_detail, + format_room_bot_list, + format_room_detail, + format_room_list, + list_messaging_rooms, + messaging_actor, + messaging_event_id, + parse_room_command, + relay_provenance_is_unknown, + resolve_room, + resolve_room_picker_choice, + room_bot_picker_choices, + room_picker_choices, + retry_room, + send_to_room, + stop_room, +) +from gateway.platforms.base import MessageEvent, MessageType, SendResult +from gateway.session import SessionSource +from hermes_cli.commands import resolve_command +from tui_gateway.hosted_room_service import HostedRoomService + + +class _FakeService: + def __init__(self, db_path): + self.db_path = db_path + self.sent = [] + self.stopped = [] + self.retried = [] + self.room_status = {"running": True, "working": False, "blocked": False} + + def status(self, _room_id): + return dict(self.room_status) + + def send(self, **kwargs): + self.sent.append(kwargs) + return {"seq": 1} + + def stop_room(self, room_id, *, cancel_id): + self.stopped.append((room_id, cancel_id)) + return 2 + + def retry_room_task(self, room_id, *, task_id, retry_id=None): + self.retried.append((room_id, task_id, retry_id)) + return {"status": "queued"} + + +class _TestHostedRoomService(HostedRoomService): + """Hosted service with a fixed two-profile test roster.""" + + def __init__(self, db_path): + super().__init__(ModuleType("test_hosted_room_server"), db_path=db_path) + + def local_profiles(self) -> tuple[str, ...]: + return ("default", "ops") + + +class _ImmediateRPC: + """In-process room worker transport that settles without a model call.""" + + def __init__(self): + self.sessions = {} + + def resolve_exact(self, *, profile, title, source): + return self.sessions.get((profile, title)) + + def create(self, *, profile, title, source): + session = {"session_id": f"{profile}-session", "title": title} + self.sessions[(profile, title)] = session + return session + + def resume(self, *, profile, session_id, source): + return {"session_id": session_id} + + def submit( + self, + *, + profile, + session_id, + prompt, + source, + task, + execution_generation, + on_terminal, + ): + on_terminal({"status": "settled", "text": f"reply from {profile}"}) + return {"accepted": True} + + def history(self, *, profile, session_id, source): + return [] + + def info(self, *, profile, session_id, source): + return {"active": False, "task_id": None} + + def interrupt(self, *, profile, session_id, source, expected_task_id): + return {"interrupted": True} + + +class _HoldingRPC(_ImmediateRPC): + """Keep a turn active until the owner observes a durable stop intent.""" + + def __init__(self): + super().__init__() + self.active = {} + self.interrupts = [] + + def create(self, *, profile, title, source): + session = super().create(profile=profile, title=title, source=source) + self.active[session["session_id"]] = {"active": False, "task_id": None} + return session + + def submit( + self, + *, + profile, + session_id, + prompt, + source, + task, + execution_generation, + on_terminal, + ): + self.active[session_id] = {"active": True, "task_id": task.task_id} + return {"accepted": True} + + def info(self, *, profile, session_id, source): + return dict(self.active[session_id]) + + def interrupt(self, *, profile, session_id, source, expected_task_id): + state = self.active[session_id] + if state["task_id"] != expected_task_id: + return {"interrupted": False} + state["active"] = False + self.interrupts.append(expected_task_id) + return {"interrupted": True} + + +def _wait_for(predicate, timeout=2.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.01) + raise AssertionError("condition was not reached") + + +def _seed_rooms(tmp_path): + db = tmp_path / "state.db" + authority = "install:test-gateway" + first = hosted_rooms.create_room( + db, + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": "ops", "handle": "ops", "display_name": "Operations"}, + ], + authority_gateway_id=authority, + ) + second = hosted_rooms.create_room( + db, + room_id="research-room", + name="Research room", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": "research", "handle": "research"}, + ], + authority_gateway_id=authority, + ) + return db, first, second + + +def test_secondary_profile_only_lists_rooms_in_its_frozen_roster(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + + assert {room["room_id"] for room in list_messaging_rooms(service)} == { + "release-room", + "research-room", + } + assert [ + room["room_id"] + for room in list_messaging_rooms(service, profile="ops") + ] == ["release-room"] + assert [ + room["room_id"] + for room in list_messaging_rooms(service, profile="research") + ] == ["research-room"] + + +def _event( + text: str, + *, + platform: Platform = Platform.SIGNAL, + user_id: str = "user-1", + message_id: str = "message-1", + media: bool = False, + media_urls: list[str] | None = None, + media_types: list[str] | None = None, + is_bot: bool = False, + chat_type: str = "dm", + is_one_to_one: bool | None = True, +) -> MessageEvent: + source = SessionSource( + platform=platform, + chat_id=f"chat-{platform.value}", + chat_type=chat_type, + user_id=user_id, + user_name="Display Name", + is_bot=is_bot, + ) + source.is_one_to_one = is_one_to_one + return MessageEvent( + text=text, + message_type=MessageType.COMMAND, + user_id=user_id, + user_name="Display Name", + message_id=message_id, + media_urls=media_urls if media_urls is not None else ["/tmp/image.png"] if media else [], + media_types=media_types if media_types is not None else ["image/png"] if media else [], + source=source, + ) + + +def _runner(*, platform: Platform = Platform.SIGNAL, extra=None): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + effective_extra = ( + {"allow_admin_from": ["user-1"]} if extra is None else extra + ) + runner.config = GatewayConfig( + platforms={ + platform: PlatformConfig( + enabled=True, + token="***", + extra=effective_extra, + ) + } + ) + runner.adapters = { + platform: SimpleNamespace( + typed_command_prefix="!" if platform in {Platform.MATRIX, Platform.SLACK} else "/" + ) + } + return runner + + +class _PickerAdapter: + typed_command_prefix = "/" + + def __init__(self): + self.calls = [] + + async def send_choice_picker(self, **kwargs): + self.calls.append(kwargs) + return SendResult(success=True, message_id="picker-1") + + +def _seed_classic_projection(home, *, room_id="classic-room"): + import yaml + + home.mkdir(parents=True, exist_ok=True) + (home / "profile.yaml").write_text( + yaml.safe_dump( + { + "ui_meta": { + "hermes-bots-groups": { + "version": 3, + "updatedAt": 2000, + "rooms": { + f"id:{room_id}": { + "name": "Desktop planning", + "roomId": room_id, + "hosted": None, + "desktopAuthorityHash": hashlib.sha256( + b"authority:test" + ).hexdigest(), + "members": [ + {"name": "default", "handle": "hermes"}, + {"name": "reviewer", "handle": "reviewer"}, + ], + "log": [ + { + "id": "message-1", + "from": {"kind": "user", "name": "You"}, + "text": "Review the plan", + "at": 1000, + "thread": "thread-1", + }, + { + "id": "message-2", + "from": {"kind": "member", "name": "Reviewer"}, + "text": "The rollout needs a rollback step.", + "at": 2000, + "thread": "thread-1", + }, + ], + } + }, + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + +def test_room_commands_are_gateway_dispatchable_without_interrupting_agent(): + group = resolve_command("group") + assert group is not None and group.gateway_only and group.busy_policy == "dispatch" + assert group.subcommands == () + assert resolve_command("groups") is None + assert resolve_command("rooms") is None + + +def test_classic_room_projection_is_listed_with_recent_activity(tmp_path, monkeypatch): + home = tmp_path / "hermes" + _seed_classic_projection(home) + monkeypatch.setenv("HERMES_HOME", str(home)) + service = _FakeService(tmp_path / "state.db") + + rooms = list_messaging_rooms(service) + + assert len(rooms) == 1 + assert rooms[0]["_room_mode"] == "desktop" + assert rooms[0]["desktop_available"] is False + detail = format_room_detail(service, rooms[0]) + assert "💬 **Desktop planning**" in detail + assert "🟡 waiting for Desktop" in detail + assert "• **Reviewer:** The rollout needs a rollback step." in detail + listing = format_room_list(service) + assert listing.startswith("👥 **Group Chats**\n") + assert "🧭 **Controls**\nCheck: `/group `" in listing + assert "Send: `/group send `" in listing + assert "Stop: `/group stop`" in listing + + +@pytest.mark.asyncio +async def test_routed_profile_lists_its_own_classic_group_chats(tmp_path, monkeypatch): + home = tmp_path / "hermes" + _seed_classic_projection(home / "profiles" / "worker", room_id="worker-room") + monkeypatch.setenv("HERMES_HOME", str(home)) + service = _FakeService(tmp_path / "state.db") + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group") + event.source.profile = "worker" + + listing = await _runner()._handle_rooms_command(event) + + assert "Desktop planning" in listing + assert "No Group Chats yet" not in listing + + +@pytest.mark.asyncio +async def test_name_keyed_legacy_group_chat_cannot_be_mutated_by_stale_number( + tmp_path, monkeypatch +): + import yaml + + home = tmp_path / "hermes" + _seed_classic_projection(home) + profile = home / "profile.yaml" + raw = yaml.safe_load(profile.read_text(encoding="utf-8")) + snapshot = raw["ui_meta"]["hermes-bots-groups"] + snapshot["version"] = 2 + legacy = snapshot["rooms"].pop("id:classic-room") + legacy.pop("roomId") + snapshot["rooms"] = {"Desktop planning": legacy} + profile.write_text(yaml.safe_dump(raw), encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + service = _FakeService(tmp_path / "state.db") + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_rooms_command( + _event("/group 1 send do not misroute", message_id="legacy-number") + ) + + assert result == ( + "Open this older Group Chat once in the latest Hermes Desktop before " + "changing it from messaging." + ) + + +def test_participant_gateway_lists_reads_and_controls_remote_room( + tmp_path, monkeypatch +): + db = tmp_path / "state.db" + now = time.time() + hosted_room_controls.save_peer_control_link( + db, + room_id="remote-room", + member_id="reviewer", + room_name="Release planning", + member_count=2, + home_url="https://home.example.test", + authority_gateway_id="install:home", + authority_epoch=3, + control_token="A" * 43, + expires_at=now + 600, + now=now, + ) + calls = [] + + class _RemoteClient: + def __init__(self, link): + assert link.control_token == "A" * 43 + + def summary(self): + return { + "room": { + "room_id": "remote-room", + "name": "Release planning", + "members": [ + {"member_id": "author", "display_name": "Author"}, + {"member_id": "reviewer", "display_name": "Reviewer"}, + ], + "authority_gateway_id": "install:home", + "authority_epoch": 3, + }, + "status": {"working": True, "blocked": False, "counts": {}}, + "events": [ + { + "kind": "message.member", + "actor": {"id": "reviewer"}, + "payload": {"member_id": "reviewer", "text": "Ready."}, + } + ], + } + + def mutate(self, **kwargs): + calls.append(kwargs) + if kwargs["action"] == "retry": + return {"action": "retry", "processed": 1} + return {"action": kwargs["action"]} + + monkeypatch.setattr(hosted_room_messaging, "RoomControlHTTPClient", _RemoteClient) + monkeypatch.setattr( + hosted_rooms, + "local_authority_gateway_id", + lambda: "install:participant", + ) + service = _FakeService(db) + + rooms = list_messaging_rooms(service) + assert [(room["name"], room["_room_mode"], room["member_count"]) for room in rooms] == [ + ("Release planning", "remote", 2) + ] + assert "⚪ **1. Release planning** · connected · 2 Bots" in format_room_list(service) + detail = format_room_detail(service, rooms[0]) + assert "💬 **Release planning**" in detail + assert "🟡 work queued or running" in detail + assert "• **Reviewer:** Ready." in detail + assert "A" * 43 not in repr(rooms) + assert send_to_room( + service, + rooms[0], + _event("/group 1 send hello", message_id="remote-send"), + "hello", + ) == "Queued in Release planning." + assert stop_room( + service, + rooms[0], + _event("/group 1 stop", message_id="remote-stop"), + ) == "Stop requested for Release planning. Active work will stop safely." + assert retry_room( + service, + rooms[0], + _event("/group 1 retry", message_id="remote-retry"), + ) == "Retry checked for Release planning (1 task)." + assert [call["action"] for call in calls] == ["send", "stop", "retry"] + assert calls[0]["actor_display_name"] == "Display Name via Signal" + + +def test_legacy_projection_uses_the_same_name_identity_as_new_desktop(tmp_path, monkeypatch): + import yaml + + home = tmp_path / "hermes" + home.mkdir(parents=True) + (home / "profile.yaml").write_text( + yaml.safe_dump( + { + "ui_meta": { + "hermes-bots-groups": { + "version": 2, + "rooms": { + "Legacy planning": { + "name": "Legacy planning", + "members": [{"name": "default"}], + "log": [], + } + }, + } + } + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + room = list_messaging_rooms(_FakeService(tmp_path / "state.db"))[0] + + assert room["room_id"] == "name:Legacy planning" + + +def test_more_than_128_classic_rooms_list_without_disabling_controls(tmp_path, monkeypatch): + import yaml + + home = tmp_path / "hermes" + home.mkdir(parents=True) + rooms = { + f"id:room-{index}": { + "name": f"Group chat {index}", + "roomId": f"room-{index}", + "members": [{"name": "default"}], + "log": [], + } + for index in range(260) + } + (home / "profile.yaml").write_text( + yaml.safe_dump( + {"ui_meta": {"hermes-bots-groups": {"version": 3, "rooms": rooms}}} + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + listed = list_messaging_rooms(_FakeService(tmp_path / "state.db")) + + assert len(listed) == 260 + assert len({room["messaging_ref"] for room in listed}) == 260 + + +def test_malformed_projected_room_does_not_hide_healthy_group_chats(tmp_path, monkeypatch): + import yaml + + home = tmp_path / "hermes" + _seed_classic_projection(home) + profile = home / "profile.yaml" + raw = yaml.safe_load(profile.read_text(encoding="utf-8")) + raw["ui_meta"]["hermes-bots-groups"]["rooms"]["id:broken"] = { + "name": "Broken", + "roomId": "x" * 201, + "desktopAuthorityHash": hashlib.sha256(b"authority:broken").hexdigest(), + "members": [{"name": "default"}], + "log": [], + } + profile.write_text(yaml.safe_dump(raw), encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + + rooms = list_messaging_rooms(_FakeService(tmp_path / "state.db")) + + assert [room["room_id"] for room in rooms] == ["classic-room"] + + +def test_classic_room_send_and_stop_wait_for_desktop(tmp_path, monkeypatch): + from gateway import desktop_room_mailbox + + home = tmp_path / "hermes" + _seed_classic_projection(home) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr( + hosted_rooms, + "local_authority_gateway_id", + lambda: "install:test-gateway", + ) + service = _FakeService(tmp_path / "state.db") + room = list_messaging_rooms(service)[0] + + sent = send_to_room( + service, + room, + _event("/group 1 send hello", message_id="send-1"), + "hello", + ) + + assert sent == "Saved for Desktop planning. Open or update Hermes Desktop to continue." + commands = desktop_room_mailbox.claim_commands( + desktop_room_mailbox.default_db_path(), + consumer_id="desktop:test", + room_authorities=[{ + "room_id": "classic-room", + "authority_token": "authority:test", + }], + ) + assert [(item["action"], item["payload"]) for item in commands] == [ + ( + "send", + { + "actor_display_name": "Display Name via Signal", + "message": "hello", + "recipients": [ + {"handle": "hermes", "name": "default"}, + {"handle": "reviewer", "name": "reviewer"}, + ], + }, + ) + ] + stopped = stop_room( + service, + room, + _event("/group 1 stop", message_id="stop-1"), + ) + assert stopped == ( + "Stop saved for Desktop planning. Open or update Hermes Desktop to apply it." + ) + stop_commands = desktop_room_mailbox.claim_commands( + desktop_room_mailbox.default_db_path(), + consumer_id="desktop:test", + room_authorities=[{ + "room_id": "classic-room", + "authority_token": "authority:test", + }], + actions=["stop"], + ) + assert [(item["action"], item["payload"]) for item in stop_commands] == [ + ( + "stop", + { + "target_command_id": commands[0]["command_id"], + "target_thread_id": "thread-1", + }, + ) + ] + + +def test_legacy_desktop_room_control_requests_one_current_desktop_open(tmp_path, monkeypatch): + import yaml + + home = tmp_path / "hermes" + _seed_classic_projection(home) + profile = home / "profile.yaml" + raw = yaml.safe_load(profile.read_text(encoding="utf-8")) + del raw["ui_meta"]["hermes-bots-groups"]["rooms"]["id:classic-room"][ + "desktopAuthorityHash" + ] + profile.write_text(yaml.safe_dump(raw), encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + room = list_messaging_rooms(_FakeService(tmp_path / "state.db"))[0] + + with pytest.raises(RoomControlError, match="Open this Group Chat once"): + send_to_room( + _FakeService(tmp_path / "state.db"), + room, + _event("/group 1 send hello", message_id="legacy-send"), + "hello", + ) + + +def test_classic_room_detail_surfaces_failed_command_recovery(tmp_path, monkeypatch): + from gateway import desktop_room_mailbox + + home = tmp_path / "hermes" + _seed_classic_projection(home) + monkeypatch.setenv("HERMES_HOME", str(home)) + db = desktop_room_mailbox.default_db_path() + desktop_room_mailbox.enqueue_command( + db, + command_id="messaging:failed", + room_id="classic-room", + authority_hash=hashlib.sha256(b"authority:test").hexdigest(), + action="send", + payload={"message": "hello"}, + ) + claimed = desktop_room_mailbox.claim_commands( + db, + consumer_id="desktop:test", + room_authorities=[{ + "room_id": "classic-room", + "authority_token": "authority:test", + }], + )[0] + desktop_room_mailbox.complete_command( + db, + consumer_id="desktop:test", + command_id="messaging:failed", + lease_token=claimed["lease_token"], + success=False, + result={"message": "route missing"}, + ) + service = _FakeService(tmp_path / "state.db") + room = list_messaging_rooms(service)[0] + + detail = format_room_detail(service, room) + + assert "💬 **Desktop planning**" in detail + assert "⚠️ needs attention" in detail + assert "The latest command could not be applied." in detail + + +def test_classic_retry_requeues_all_expired_commands_and_replays_receipt( + tmp_path, monkeypatch +): + from gateway import desktop_room_mailbox + + home = tmp_path / "hermes" + _seed_classic_projection(home) + monkeypatch.setenv("HERMES_HOME", str(home)) + db = desktop_room_mailbox.default_db_path() + now = [100.0] + for index in range(2): + desktop_room_mailbox.enqueue_command( + db, + command_id=f"messaging:expired-{index}", + room_id="classic-room", + authority_hash=hashlib.sha256(b"authority:test").hexdigest(), + action="send", + payload={"message": str(index)}, + clock=lambda: now[0], + ) + now[0] += 1 + now[0] += desktop_room_mailbox.PENDING_TTL_SECONDS + 1 + assert desktop_room_mailbox.claim_commands( + db, + consumer_id="desktop:test", + room_authorities=[{ + "room_id": "classic-room", + "authority_token": "authority:test", + }], + clock=lambda: now[0], + ) == [] + + service = _FakeService(db) + room = list_messaging_rooms(service)[0] + event = _event("/group 1 retry", message_id="retry-expired") + result = retry_room(service, room, event) + replay = retry_room(service, room, event) + + assert result == "Retry queued for Desktop planning (2 commands)." + assert replay == result + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ('1 send "hi there"', ("send", "1", "hi there")), + ("1 send -- quoted style", ("send", "1", "quoted style")), + ("1 stop", ("stop", "1", "")), + ("1 retry", ("retry", "1", "")), + ("1 approve", ("approve", "1", "")), + ("1 approve A1B2C3D4", ("approve", "1", "A1B2C3D4")), + ("1 deny", ("deny", "1", "")), + ], +) +def test_parse_room_command_keeps_names_and_message_content(raw, expected): + parsed = parse_room_command(raw) + assert (parsed.action, parsed.room_query, parsed.message) == expected + + +@pytest.mark.parametrize("raw", ["", "send room", "send -- hello", "stop"]) +def test_parse_room_command_returns_actionable_usage(raw): + with pytest.raises(RoomControlError, match="Use `/group"): + parse_room_command(raw) + + +@pytest.mark.asyncio +async def test_messaging_approval_command_uses_exact_pending_coordinates( + tmp_path, monkeypatch +): + db, _release, _research = _seed_rooms(tmp_path) + service = MessagingRoomBackend(db_path=db) + captured = {} + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + + def submit(_service, room, **kwargs): + captured.update({"room": room, **kwargs}) + return ( + 1, + { + "member_id": "ops", + "task_id": "task-1", + "execution_generation": 2, + "request_id": "request-1", + }, + {"queued": False, "choice": "once"}, + ) + + monkeypatch.setattr( + "gateway.hosted_room_messaging_approvals.submit_room_approval", + submit, + ) + + result = await _runner()._handle_rooms_command( + _event("/group 1 approve A1B2C3D4", message_id="approval-message-1") + ) + + assert result == "Approved once for Operations. Check: `/group 1`." + assert captured["room"]["room_id"] == "release-room" + assert captured["choice"] == "once" + assert captured["selection"] == "A1B2C3D4" + assert str(captured["command_id"]).startswith("approval:messaging:") + + +@pytest.mark.asyncio +async def test_approval_redelivery_returns_original_terminal_receipt_before_room_number( + tmp_path, + monkeypatch, +): + from gateway import hosted_room_messaging_approvals as approvals + + db, release, _ = _seed_rooms(tmp_path) + service = MessagingRoomBackend(db_path=db) + pending = approvals.persist_pending_approval( + db, + room_id="release-room", + member_id="ops", + action={ + "kind": "approval", + "authority_gateway_id": release["authority_gateway_id"], + "authority_epoch": release["authority_epoch"], + "task_id": "task-1", + "execution_generation": 1, + "request_id": "request-1", + "approval": {"choices": ["once", "deny"]}, + }, + ) + event = _event( + f"/group 1 approve {approvals.approval_reference(pending)}", + message_id="terminal-redelivery", + ) + command_id = f"approval:{messaging_event_id(event)}" + approvals.begin_approval_command( + db, + command_id=command_id, + pending=pending, + choice="once", + ) + approvals.complete_approval_command( + db, + command_id=command_id, + result="Approval expired with the original Group Chat.", + ) + hosted_rooms.disband_room( + db, + room_id="release-room", + expected_gateway_id=str(release["authority_gateway_id"]), + expected_epoch=int(release["authority_epoch"]), + ) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + + result = await _runner()._handle_rooms_command(event) + + assert result == "Approval expired with the original Group Chat." + + +def test_room_resolution_is_exact_then_unique_prefix_then_substring(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + rooms = hosted_rooms.list_rooms(db) + assert resolve_room(rooms, "release-room")["name"] == "Release room" + assert resolve_room(rooms, "Research")["room_id"] == "research-room" + assert resolve_room(rooms, "lease")["room_id"] == "release-room" + + +def test_room_numbers_stay_stable_and_are_not_reused_after_disband(tmp_path): + db, first, second = _seed_rooms(tmp_path) + service = _FakeService(db) + initial = { + room["room_id"]: room["messaging_ref"] + for room in list_messaging_rooms(service) + } + + hosted_rooms.disband_room( + db, + room_id=first["room_id"], + expected_gateway_id="install:test-gateway", + expected_epoch=1, + ) + third = hosted_rooms.create_room( + db, + room_id="stop-signals", + name="Stop signals", + members=[], + authority_gateway_id="install:test-gateway", + ) + current = list_messaging_rooms(service) + refs = {room["room_id"]: room["messaging_ref"] for room in current} + + assert refs[second["room_id"]] == initial[second["room_id"]] + assert refs[third["room_id"]] > max(initial.values()) + assert resolve_room(current, str(refs[third["room_id"]]))["name"] == "Stop signals" + + +def test_missing_room_number_fails_closed_without_matching_a_numeric_name(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + hosted_rooms.create_room( + db, + room_id="roadmap-2026", + name="2026 roadmap", + members=[], + authority_gateway_id="install:test-gateway", + ) + rooms = list_messaging_rooms(_FakeService(db)) + + with pytest.raises(RoomControlError, match="numbered 2026"): + resolve_room(rooms, "2026") + + +def test_numeric_internal_id_requires_an_explicit_advanced_escape(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + numeric_id = hosted_rooms.create_room( + db, + room_id="1", + name="Legacy numeric ID", + members=[], + authority_gateway_id="install:test-gateway", + ) + rooms = list_messaging_rooms(_FakeService(db)) + + assert resolve_room(rooms, "1")["room_id"] != numeric_id["room_id"] + assert resolve_room(rooms, "id:1")["room_id"] == numeric_id["room_id"] + + +def test_room_numbers_allocate_once_across_concurrent_gateway_processes(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + + def load_refs(_index): + service = _FakeService(db) + return { + room["room_id"]: room["messaging_ref"] + for room in list_messaging_rooms(service) + } + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(load_refs, range(24))) + + assert all(result == results[0] for result in results) + assert len(set(results[0].values())) == 2 + + +def test_room_resolution_explains_ambiguity_and_missing_names(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + hosted_rooms.create_room( + db, + room_id="release-notes", + name="Release notes", + members=[], + authority_gateway_id="install:test-gateway", + ) + rooms = hosted_rooms.list_rooms(db) + with pytest.raises(RoomControlError, match="matches several group chats"): + resolve_room(rooms, "release") + with pytest.raises(RoomControlError, match="No group chat"): + resolve_room(rooms, "missing") + + +@pytest.mark.asyncio +async def test_reserved_word_room_name_opens_detail_without_triggering_stop( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + hosted_rooms.create_room( + db, + room_id="stop-signals", + name="Stop signals", + members=[], + authority_gateway_id="install:test-gateway", + ) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_rooms_command(_event("/group Stop signals")) + + assert result.startswith("💬 **Stop signals**\n") + assert service.stopped == [] + + +@pytest.mark.asyncio +async def test_numeric_action_command_wins_over_a_command_shaped_room_name( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + hosted_rooms.create_room( + db, + room_id="command-shaped-room", + name="1 stop", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": "ops", "handle": "ops"}, + ], + authority_gateway_id="install:test-gateway", + ) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_rooms_command( + _event("/group 1 stop", message_id="stop-command-shaped") + ) + + assert result.startswith("Stop requested for Release room") + assert service.stopped[0][0] == "release-room" + + +@pytest.mark.asyncio +async def test_group_list_keyword_uses_the_same_helpful_listing(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: _FakeService(db), + ) + + result = await _runner()._handle_rooms_command(_event("/group list")) + + assert result.startswith("👥 **Group Chats**\n") + assert "🧭 **Controls**\nCheck: `/group `" in result + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "platform", + [Platform.TELEGRAM, Platform.DISCORD, Platform.MATRIX], +) +async def test_bare_group_uses_native_picker_and_selection_refreshes_detail( + tmp_path, + monkeypatch, + platform, +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + adapter = _PickerAdapter() + runner = _runner(platform=platform) + runner.adapters[platform] = adapter + runner._thread_metadata_for_source = lambda source, anchor=None: {} + runner._reply_anchor_for_event = lambda event: None + + result = await runner._handle_rooms_command( + _event("/group", platform=platform) + ) + + assert result is None + assert len(adapter.calls) == 1 + call = adapter.calls[0] + assert call["title"].startswith("👥 Group Chats\n") + assert all(choice["value"].startswith("room-") for choice in call["choices"]) + assert len({choice["value"] for choice in call["choices"]}) == 2 + release_value = next( + choice["value"] for choice in call["choices"] if "Release" in choice["label"] + ) + + detail = await call["on_choice_selected"]("chat-telegram", release_value) + + assert "💬 **Release room**" in detail + assert "🤖 **Bots**" in detail + assert "🧭 **Controls**" in detail + + hosted_rooms.disband_room( + db, + room_id="release-room", + expected_gateway_id="install:test-gateway", + expected_epoch=1, + ) + missing = await call["on_choice_selected"]("chat-telegram", release_value) + assert missing == "This Group Chat is no longer available. Run the command again." + + +@pytest.mark.asyncio +async def test_group_bots_drills_into_native_participant_picker( + tmp_path, + monkeypatch, +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + adapter = _PickerAdapter() + runner = _runner(platform=Platform.TELEGRAM) + runner.adapters[Platform.TELEGRAM] = adapter + runner._thread_metadata_for_source = lambda source, anchor=None: {} + runner._reply_anchor_for_event = lambda event: None + + result = await runner._handle_rooms_command( + _event("/group 1 bots", platform=Platform.TELEGRAM) + ) + + assert result is None + call = adapter.calls[0] + assert call["title"].startswith("🤖 Bots\n") + assert all(choice["value"].startswith("p=") for choice in call["choices"]) + ops_value = next( + choice["value"] for choice in call["choices"] if "Operations" in choice["label"] + ) + detail = await call["on_choice_selected"]("chat-telegram", ops_value) + assert detail.startswith("🤖 **Operations**") + assert "`@ops`" in detail + + +@pytest.mark.asyncio +async def test_group_approvals_use_native_one_tap_choices(tmp_path, monkeypatch): + from gateway import hosted_room_messaging_approvals as approvals + + db, release, _ = _seed_rooms(tmp_path) + service = MessagingRoomBackend(db_path=db) + approvals.persist_pending_approval( + db, + room_id="release-room", + member_id="ops", + action={ + "kind": "approval", + "authority_gateway_id": str(release["authority_gateway_id"]), + "authority_epoch": int(release["authority_epoch"]), + "task_id": "task-approval-1", + "execution_generation": 2, + "request_id": "request-approval-1", + "approval": { + "description": "Run focused tests", + "command": "pytest -q tests/focused", + "choices": ["once", "deny"], + }, + }, + ) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + adapter = _PickerAdapter() + runner = _runner(platform=Platform.TELEGRAM) + runner.adapters[Platform.TELEGRAM] = adapter + runner._thread_metadata_for_source = lambda source, anchor=None: {} + runner._reply_anchor_for_event = lambda event: None + + result = await runner._handle_rooms_command( + _event("/group 1 approvals", platform=Platform.TELEGRAM) + ) + + assert result is None + call = adapter.calls[0] + assert call["title"].startswith("⚠️ **Approval needed**\n") + assert "**Operations**: Run focused tests" in call["title"] + assert call["choices"][0]["label"] == "✓ 1. Approve once · Operations · @ops" + assert call["choices"][1]["label"] == "✕ 1. Deny · Operations · @ops" + denied = await call["on_choice_selected"]( + "chat-telegram", + call["choices"][1]["value"], + ) + assert denied == "Decision sent for Operations." + commands = approvals.list_pending_approval_commands( + db, + room_id="release-room", + ) + assert [(command["choice"], command["request_id"]) for command in commands] == [ + ("deny", "request-approval-1") + ] + + +@pytest.mark.asyncio +async def test_group_bot_controls_fall_back_to_rich_text(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: _FakeService(db), + ) + + listing = await _runner()._handle_rooms_command(_event("/group 1 bots")) + detail = await _runner()._handle_rooms_command(_event("/group 1 bot 2")) + + assert "🤖 **Bots in Release room**" in listing + assert "🧭 **Controls**" in listing + assert detail.startswith("🤖 **Operations**") + assert "Message this Bot: `/group 1 send @ops `" in detail + + +@pytest.mark.asyncio +async def test_native_group_picker_hides_unexpected_callback_details( + tmp_path, + monkeypatch, +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + + def fail_detail(*_args, **_kwargs): + raise RuntimeError("private path: /opt/data/state.db") + + monkeypatch.setattr(hosted_room_messaging, "format_room_detail", fail_detail) + adapter = _PickerAdapter() + runner = _runner(platform=Platform.TELEGRAM) + runner.adapters[Platform.TELEGRAM] = adapter + runner._thread_metadata_for_source = lambda source, anchor=None: {} + runner._reply_anchor_for_event = lambda event: None + + await runner._handle_rooms_command(_event("/group", platform=Platform.TELEGRAM)) + result = await adapter.calls[0]["on_choice_selected"]( + "chat-telegram", + adapter.calls[0]["choices"][0]["value"], + ) + + assert result == "Couldn’t load that Group Chat. Run `/group` again." + assert "/opt/data" not in result + + +@pytest.mark.asyncio +async def test_group_list_pages_keep_every_stable_number_reachable(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + for index in range(3, 11): + hosted_rooms.create_room( + db, + room_id=f"room-{index}", + name=f"Room {index}", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": f"bot-{index}", "handle": f"bot-{index}"}, + ], + authority_gateway_id="install:test-gateway", + ) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: _FakeService(db), + ) + + first = await _runner()._handle_rooms_command(_event("/group list")) + second = await _runner()._handle_rooms_command(_event("/group list 2")) + + assert "page 1 of 2" in first + assert "More: `/group list 2`" in first + assert "page 2 of 2" in second + assert "9. Room 9" in second + assert "10. Room 10" in second + + +@pytest.mark.asyncio +async def test_mutating_room_commands_require_the_stable_number(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_room_command( + _event("/group stop Release room") + ) + + assert result == ( + "Use `/group send `, `/group retry`, or " + "`/group stop`." + ) + assert service.stopped == [] + + +@pytest.mark.asyncio +async def test_retry_requeues_only_retryable_hosted_tasks(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + service.room_status = { + "running": True, + "working": False, + "blocked": True, + "pending_actions": [ + {"kind": "retry", "task_id": "task-1"}, + {"kind": "approval", "task_id": "task-2"}, + ], + } + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + event = _event("/group 1 retry", message_id="retry-1") + result = await _runner()._handle_room_command(event) + replay = await _runner()._handle_room_command(event) + + assert result.startswith("Retry queued for Release room (1 task).") + assert replay == result + assert service.retried == [ + ( + "release-room", + "task-1", + hosted_room_controls.control_retry_attempt_id( + f"retry:{messaging_event_id(event)}", + "task-1", + ), + ) + ] + + +@pytest.mark.asyncio +async def test_retry_is_durably_handed_to_the_active_worker_process( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + service.room_status = { + "running": True, + "working": False, + "blocked": True, + "pending_actions": [{"kind": "retry", "task_id": "task-1"}], + } + + def lease_held(_room_id, *, task_id, retry_id=None): + assert task_id == "task-1" + assert retry_id == hosted_room_controls.control_retry_attempt_id( + f"retry:{messaging_event_id(event)}", + task_id, + ) + raise hosted_room_driver.LeaseHeldError( + "room driver lease is held by another generation" + ) + + service.retry_room_task = lease_held + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group 1 retry", message_id="retry-cross-process") + + result = await _runner()._handle_room_command(event) + replay = await _runner()._handle_room_command(event) + + assert result.startswith("Retry queued for Release room (1 task).") + assert replay == result + pending = hosted_room_controls.load_pending_control_retries( + db, + room_id="release-room", + ) + assert len(pending) == 1 + assert pending[0].task_ids == ("task-1",) + assert pending[0].command_id.startswith("worker:retry:") + + +def test_room_list_and_detail_are_bounded_and_user_facing(tmp_path): + db, release, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + hosted_rooms.append_event( + db, + room_id=release["room_id"], + event_id="user-1", + kind="message.user", + actor={"kind": "user", "id": "messaging:signal:abc", "display_name": "Signal"}, + authority_gateway_id="install:test-gateway", + authority_epoch=1, + payload={"text": "Please inspect the release", "thread_id": "thread-1"}, + ) + hosted_rooms.append_event( + db, + room_id=release["room_id"], + event_id="member-1", + kind="message.member", + actor={"kind": "member", "id": "ops"}, + payload={ + "member_id": "ops", + "text": "The release is ready", + "thread_id": "thread-1", + "task_id": "task-1", + "turn_id": "turn-1", + "round_index": 0, + }, + authority_gateway_id="install:test-gateway", + authority_epoch=1, + ) + + listing = format_room_list(service) + assert "👥 **Group Chats**" in listing + assert "🟡 **1. Release room** · waiting for its Bots · 2 Bots" in listing + assert "🧭 **Controls**\nCheck: `/group `" in listing + assert "Send: `/group send `" in listing + assert "Retry: `/group retry`" in listing + assert "Stop: `/group stop`" in listing + detail = format_room_detail(service, release) + assert "**Signal:** Please inspect the release" in detail + assert "**Operations:** The release is ready" in detail + assert "🤖 **Bots**" in detail + assert "• Operations (`@ops`)" in detail + assert "Send: `/group 1 send `" in detail + assert "\n\n────────\n🧭 **Controls**\nSend:" in detail + assert "Retry:" not in detail + assert "Stop:" not in detail + assert "Message one Bot: `/group 1 send @handle `" in detail + + +def test_room_picker_choices_are_bounded_stable_and_user_facing(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + rooms = list_messaging_rooms(service) + + choices = room_picker_choices(service, rooms) + + assert all(choice["value"].startswith("room-") for choice in choices) + assert len({choice["value"] for choice in choices}) == 2 + assert {choice["label"] for choice in choices} == { + "🟢 1. Release room (2)", + "🟢 2. Research room (2)", + } + assert all(choice["full_width"] is True for choice in choices) + assert all("release-room" not in choice["label"] for choice in choices) + replacement = {**rooms[0], "room_id": "replacement-room", "messaging_ref": 1} + with pytest.raises(RoomControlError, match="no longer available"): + resolve_room_picker_choice([replacement], choices[0]["value"]) + + +def test_group_bot_picker_and_details_expose_only_useful_controls(tmp_path): + db, release, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + + choices = room_bot_picker_choices(service, release) + listing = format_room_bot_list(service, release) + detail = format_room_bot_detail(service, release, "@ops") + + assert all(choice["value"].startswith("p=") for choice in choices) + assert len({choice["value"] for choice in choices}) == 2 + assert [choice["label"] for choice in choices] == [ + "🤖 hermes · hermes", + "🤖 Operations · ops", + ] + assert "🤖 **Bots in Release room**" in listing + assert "2. **Operations** · `@ops`" in listing + assert "Bot details: `/group 1 bot `" in listing + assert detail.startswith("🤖 **Operations**") + assert "Message this Bot: `/group 1 send @ops `" in detail + assert "Stop" not in detail + with pytest.raises(RoomControlError, match="No Bot"): + format_room_bot_detail(service, release, "9") + + +def test_room_picker_keeps_recent_rooms_reachable_when_roster_is_large(tmp_path): + db, _, _ = _seed_rooms(tmp_path) + latest = None + for index in range(3, 12): + latest = hosted_rooms.create_room( + db, + room_id=f"room-{index}", + name=f"Room {index}", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": f"bot-{index}", "handle": f"bot-{index}"}, + ], + authority_gateway_id="install:test-gateway", + ) + assert latest is not None + hosted_rooms.append_event( + db, + room_id=latest["room_id"], + event_id="latest-user-message", + kind="message.user", + actor={"kind": "user", "id": "messaging:test", "display_name": "Owner"}, + authority_gateway_id="install:test-gateway", + authority_epoch=1, + payload={"text": "Newest work", "thread_id": "thread-latest"}, + ) + service = _FakeService(db) + + choices = room_picker_choices(service, list_messaging_rooms(service)) + + assert len(choices) == 8 + assert str(latest["name"]) in choices[0]["label"] + + +def test_group_detail_escapes_untrusted_markup(tmp_path): + db = tmp_path / "state.db" + room = hosted_rooms.create_room( + db, + room_id="markup-room", + name="**Admin**", + members=[ + {"member_id": "default", "handle": "hermes"}, + {"member_id": "ops", "handle": "ops", "display_name": "*System*"}, + ], + authority_gateway_id="install:test-gateway", + ) + hosted_rooms.append_event( + db, + room_id=room["room_id"], + event_id="markup-message", + kind="message.member", + actor={"kind": "member", "id": "ops"}, + authority_gateway_id="install:test-gateway", + authority_epoch=1, + payload={ + "member_id": "ops", + "text": "*not a command* `deploy_a` > {prod} @ops", + "thread_id": "thread-markup", + }, + ) + + detail = format_room_detail(_FakeService(db), room) + room_choices = room_picker_choices( + _FakeService(db), + list_messaging_rooms(_FakeService(db)), + ) + bot_choices = room_bot_picker_choices(_FakeService(db), room) + + assert "💬 **Admin**" in detail + assert "• System (`@ops`)" in detail + safe_preview = "*not a command* `deploy_a` > {prod} @ops" + assert safe_preview in detail + assert room_choices[0]["label"] == "🟢 1. Admin (2)" + assert bot_choices[1]["label"] == "🤖 System · ops" + unsafe_room = {**room, "name": "@room [Docs](https://invalid.example)"} + unsafe_choice = room_picker_choices(_FakeService(db), [unsafe_room])[0] + assert "@room" not in unsafe_choice["label"] + assert "[" not in unsafe_choice["label"] + + from gateway.platforms.signal_format import markdown_to_signal + from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin + from plugins.platforms.slack.adapter import SlackAdapter + from plugins.platforms.telegram.adapter import TelegramAdapter + + telegram = object.__new__(TelegramAdapter).format_message(detail) + whatsapp_adapter = object.__new__(WhatsAppBehaviorMixin) + whatsapp_adapter._sanitize_outbound_text = lambda text: text + whatsapp = whatsapp_adapter.format_message(detail) + signal, _styles = markdown_to_signal(detail) + slack = object.__new__(SlackAdapter).format_message(detail) + for rendered in (telegram, whatsapp, signal, slack): + assert "**Admin**" not in rendered + assert r"\*\*Admin" not in rendered + assert safe_preview in rendered + + +def test_bot_controls_preserve_valid_colon_handles_without_collision(tmp_path): + db = tmp_path / "state.db" + long_handle = "a" * 100 + room = hosted_rooms.create_room( + db, + room_id="colon-room", + name="Operations", + members=[ + {"member_id": "prod", "handle": "ops:prod", "display_name": "Prod"}, + {"member_id": "plain", "handle": "opsprod", "display_name": "Plain"}, + {"member_id": "numeric", "handle": "1", "display_name": "Numeric"}, + {"member_id": "long", "handle": long_handle, "display_name": "Long"}, + ], + authority_gateway_id="install:test-gateway", + ) + service = _FakeService(db) + + choices = room_bot_picker_choices(service, room) + detail = format_room_bot_detail(service, room, "ops:prod") + numeric_handle = format_room_bot_detail(service, room, "@1") + first_index = format_room_bot_detail(service, room, "1") + long_detail = format_room_bot_detail(service, room, long_handle) + + assert all(choice["value"].startswith("p=") for choice in choices) + assert len({choice["value"] for choice in choices}) == 4 + assert any("ops:prod" in choice["label"] for choice in choices) + assert "Handle: `@ops:prod`" in detail + assert "send @ops:prod " in detail + assert numeric_handle.startswith("🤖 **Numeric**") + assert first_index.startswith("🤖 **Prod**") + assert f"Handle: `@{long_handle}`" in long_detail + + collision_room = hosted_rooms.create_room( + db, + room_id="collision-room", + name="Collision check", + members=[ + {"member_id": "a:b", "handle": "c", "display_name": "Same"}, + {"member_id": "a", "handle": "b:c", "display_name": "Same"}, + ], + authority_gateway_id="install:test-gateway", + ) + collision_choices = room_bot_picker_choices(service, collision_room) + assert len({choice["value"] for choice in collision_choices}) == 2 + + +def test_duplicate_bot_picker_tokens_fail_closed(tmp_path, monkeypatch): + db, release, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + hosted_room_messaging, + "_room_member_picker_value", + lambda *_args: "p=duplicate", + ) + + with pytest.raises(RoomControlError, match="No Bot"): + format_room_bot_detail(service, release, "p=duplicate") + + +def test_group_detail_never_invents_a_missing_bot_handle(tmp_path): + room = { + "room_id": "classic-room", + "name": "Planning", + "members": [{"name": "CEO Assistant"}, {"name": "Review Bot"}], + "messaging_ref": 1, + "_room_mode": "desktop", + "desktop_available": True, + "log": [], + } + + detail = format_room_detail(_FakeService(tmp_path / "state.db"), room) + + assert "• CEO Assistant" in detail + assert "@CEOAssistant" not in detail + assert "Message one Bot:" not in detail + choices = room_bot_picker_choices( + _FakeService(tmp_path / "state.db"), + room, + ) + token = choices[1]["value"] + reordered = {**room, "members": list(reversed(room["members"]))} + selected = format_room_bot_detail( + _FakeService(tmp_path / "state.db"), + reordered, + token, + ) + assert selected.startswith("🤖 **Review Bot**") + + +def test_group_detail_only_offers_actions_that_match_current_state(tmp_path): + db, release, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + + idle = format_room_detail(service, release) + assert "Send: `/group 1 send `" in idle + assert "Retry:" not in idle + assert "Stop:" not in idle + + service.room_status = {"running": True, "working": True, "blocked": False} + working = format_room_detail(service, release) + assert "Stop: `/group 1 stop`" in working + assert "Retry:" not in working + + service.room_status = {"running": True, "working": False, "blocked": True} + blocked = format_room_detail(service, release) + assert "Retry:" not in blocked + assert "Stop:" not in blocked + + service.room_status = { + "running": True, + "working": False, + "blocked": True, + "pending_actions": [{"kind": "retry", "task_id": "task-1"}], + } + retryable = format_room_detail(service, release) + assert "Retry: `/group 1 retry`" in retryable + + service.room_status = { + "running": True, + "working": True, + "blocked": True, + "counts": {"stopping": 1}, + } + stopping = format_room_detail(service, release) + assert "🟡 stopping" in stopping + assert "needs attention" not in stopping + assert "Stop:" not in stopping + + service.room_status = { + "running": False, + "working": False, + "blocked": False, + "peer_routes": [ + {"member_id": "remote", "status": "needs_reauthorization"} + ], + } + assert MessagingRoomBackend(db_path=db, service=service).status( + "release-room" + )["blocked"] is True + + classic = { + "room_id": "classic-room", + "name": "Desktop work", + "members": [{"name": "worker", "handle": "worker"}], + "messaging_ref": 3, + "_room_mode": "desktop", + "desktop_available": False, + "desktop_command": {"action": "send", "state": "pending"}, + "log": [], + } + pending = format_room_detail(service, classic) + assert "Stop: `/group 3 stop`" in pending + + +def test_group_detail_surfaces_exact_pending_approval_commands(tmp_path): + from gateway import hosted_room_messaging_approvals as approvals + + db, release, _ = _seed_rooms(tmp_path) + approvals.persist_pending_approval( + db, + room_id="release-room", + member_id="ops", + action={ + "kind": "approval", + "authority_gateway_id": str(release["authority_gateway_id"]), + "authority_epoch": int(release["authority_epoch"]), + "task_id": "task-approval-1", + "execution_generation": 2, + "request_id": "request-approval-1", + "approval": { + "description": "Run focused tests", + "command": "pytest -q tests/focused", + "choices": ["once", "deny"], + }, + }, + ) + detail = format_room_detail( + MessagingRoomBackend(db_path=db), + release, + ) + + assert "⚠️ **Approval needed**" in detail + assert "1. **Operations** · Run focused tests" in detail + assert "Actions: `/group 1 approvals`" in detail + assert "Approve once: `/group 1 approve `" in detail + assert "Deny: `/group 1 deny `" in detail + + +def test_empty_group_list_points_to_the_only_available_next_step(tmp_path): + listing = format_room_list(_FakeService(tmp_path / "state.db")) + + assert listing.startswith("👥 **No Group Chats yet**") + assert "Create one in Hermes Desktop first." in listing + assert "" not in listing diff --git a/tests/gateway/test_hosted_room_messaging_approvals.py b/tests/gateway/test_hosted_room_messaging_approvals.py new file mode 100644 index 0000000000000..9a75dc619e322 --- /dev/null +++ b/tests/gateway/test_hosted_room_messaging_approvals.py @@ -0,0 +1,710 @@ +"""Durable approval decisions from messaging Group Chat controls.""" + +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import sqlite3 +import time + +import pytest + +from gateway import hosted_room_messaging_approvals as approvals +from gateway import hosted_room_driver as driver +from gateway import hosted_rooms + + +def _action(**overrides): + return { + "kind": "approval", + "authority_gateway_id": "gateway-1", + "authority_epoch": 1, + "task_id": "task-1", + "execution_generation": 2, + "request_id": "request-1", + "approval": { + "description": "Run focused tests", + "command": "pytest -q tests/focused", + "choices": ["once", "deny"], + }, + **overrides, + } + + +def _pending(db: Path): + return approvals.persist_pending_approval( + db, + room_id="room-1", + member_id="member-1", + action=_action(), + ) + + +def test_pending_approval_is_bounded_and_cleared_exactly(tmp_path): + db = tmp_path / "state.db" + action = _action( + approval={ + "description": "x" * 1_000, + "command": "pytest\n-q\ttests/focused", + "choices": ["once", "session", "always", "deny"], + } + ) + pending = approvals.persist_pending_approval( + db, + room_id="room-1", + member_id="member-1", + action=action, + ) + + assert pending["approval"] == { + "description": "x" * approvals.MAX_APPROVAL_TEXT_CHARS, + "command": "pytest -q tests/focused", + "choices": ["once", "deny"], + } + assert approvals.clear_pending_approval( + db, + room_id="room-1", + member_id="member-1", + request_id="other-request", + ) == 0 + assert len(approvals.list_pending_approvals(db, room_id="room-1")) == 1 + assert approvals.clear_pending_approval( + db, + room_id="room-1", + member_id="member-1", + request_id="request-1", + ) == 1 + + +def test_existing_approval_table_migrates_observer_generation(tmp_path): + db = tmp_path / "state.db" + conn = sqlite3.connect(db) + conn.execute( + """CREATE TABLE hosted_room_pending_approvals ( + room_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + member_id TEXT NOT NULL, + task_id TEXT NOT NULL, + execution_generation INTEGER NOT NULL, + request_id TEXT NOT NULL, + profile TEXT NOT NULL, + session_id TEXT NOT NULL, + description TEXT NOT NULL, + command_text TEXT NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (room_id, member_id) + )""" + ) + conn.commit() + conn.close() + + pending = _pending(db) + + assert pending["observer_generation"] == "legacy" + assert approvals.list_pending_approvals(db, room_id="room-1")[0][ + "observer_generation" + ] == "legacy" + + +def test_existing_approval_table_migrates_concurrently(tmp_path): + db = tmp_path / "state.db" + conn = sqlite3.connect(db) + conn.execute( + """CREATE TABLE hosted_room_pending_approvals ( + room_id TEXT NOT NULL, + authority_gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL, + member_id TEXT NOT NULL, + task_id TEXT NOT NULL, + execution_generation INTEGER NOT NULL, + request_id TEXT NOT NULL, + profile TEXT NOT NULL, + session_id TEXT NOT NULL, + description TEXT NOT NULL, + command_text TEXT NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (room_id, member_id) + )""" + ) + conn.commit() + conn.close() + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list( + pool.map( + lambda _index: approvals.list_all_pending_approvals(db), + range(16), + ) + ) + + assert results == [[]] * 16 + conn = sqlite3.connect(db) + columns = { + str(row[1]) + for row in conn.execute("PRAGMA table_info(hosted_room_pending_approvals)") + } + conn.close() + assert {"observer_generation", "observer_lease_generation"} <= columns + + +def test_observer_lease_is_checked_after_waiting_for_write_lock(tmp_path): + db = tmp_path / "state.db" + room = hosted_rooms.create_room( + db, + room_id="room-1", + name="Lock race", + members=[ + {"member_id": "default", "profile": "default"}, + {"member_id": "member-1", "profile": "member-1"}, + ], + authority_gateway_id="gateway-1", + ) + lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id="gateway-1", + authority_epoch=int(room["authority_epoch"]), + process_generation="worker-1", + ttl_seconds=0.15, + clock=time.time, + ) + action = _action( + observer_generation="worker-1", + observer_lease_generation=lease.lease_generation, + ) + approvals.list_all_pending_approvals(db) + blocker = sqlite3.connect(db, timeout=10) + blocker.execute("BEGIN IMMEDIATE") + try: + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit( + approvals.persist_pending_approval, + db, + room_id="room-1", + member_id="member-1", + action=action, + ) + time.sleep(0.25) + blocker.commit() + with pytest.raises(approvals.MessagingApprovalObservationStale): + future.result(timeout=5) + finally: + if blocker.in_transaction: + blocker.rollback() + blocker.close() + + +def test_observer_lease_cannot_outlive_room_authority_epoch(tmp_path): + db = tmp_path / "state.db" + room = hosted_rooms.create_room( + db, + room_id="room-1", + name="Authority race", + members=[ + {"member_id": "default", "profile": "default"}, + {"member_id": "member-1", "profile": "member-1"}, + ], + authority_gateway_id="gateway-1", + ) + lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id="gateway-1", + authority_epoch=int(room["authority_epoch"]), + process_generation="worker-1", + ttl_seconds=30, + clock=time.time, + ) + action = _action( + observer_generation="worker-1", + observer_lease_generation=lease.lease_generation, + ) + hosted_rooms.claim_authority( + db, + room_id="room-1", + expected_gateway_id="gateway-1", + expected_epoch=int(room["authority_epoch"]), + new_gateway_id="gateway-1", + event_id="authority-epoch-2", + ) + + with pytest.raises(approvals.MessagingApprovalObservationStale): + approvals.persist_pending_approval( + db, + room_id="room-1", + member_id="member-1", + action=action, + ) + + +def test_command_replay_is_idempotent_and_conflicting_reuse_fails(tmp_path): + db = tmp_path / "state.db" + pending = _pending(db) + first = approvals.begin_approval_command( + db, + command_id="approval-command-1", + pending=pending, + choice="once", + ) + replay = approvals.begin_approval_command( + db, + command_id="approval-command-1", + pending=pending, + choice="once", + ) + + assert first["state"] == "pending" + assert replay["idempotent"] is True + with pytest.raises( + approvals.MessagingApprovalError, + match="different content", + ): + approvals.begin_approval_command( + db, + command_id="approval-command-1", + pending=pending, + choice="deny", + ) + + +def test_cross_process_command_waits_then_resolves_once(tmp_path): + class Service: + def __init__(self): + self.calls = [] + + def approve_room_task(self, room_id, **kwargs): + self.calls.append((room_id, kwargs)) + return {"resolved": 1} + + db = tmp_path / "state.db" + pending = _pending(db) + queued = approvals.submit_approval( + db, + service=None, + command_id="approval-command-1", + pending=pending, + choice="deny", + ) + service = Service() + resolved = approvals.submit_approval( + db, + service=service, + command_id="approval-command-1", + pending=pending, + choice="deny", + ) + replay = approvals.submit_approval( + db, + service=service, + command_id="approval-command-1", + pending=pending, + choice="deny", + ) + + assert queued["queued"] is True + assert resolved["result"] == "Denied." + assert replay["idempotent"] is True + assert service.calls == [ + ( + "room-1", + { + "member_id": "member-1", + "task_id": "task-1", + "execution_generation": 2, + "choice": "deny", + "request_id": "request-1", + }, + ) + ] + + +def test_post_journal_execution_error_is_reported_as_queued(tmp_path): + class FailingService: + def approve_room_task(self, _room_id, **_kwargs): + raise OSError("worker transport restarted") + + db = tmp_path / "state.db" + pending = _pending(db) + result = approvals.submit_approval( + db, + service=FailingService(), + command_id="approval-command-1", + pending=pending, + choice="once", + ) + + assert result["queued"] is True + assert len(approvals.list_pending_approval_commands(db, room_id="room-1")) == 1 + + +def test_post_resolution_receipt_error_is_not_reported_as_rejection( + tmp_path, + monkeypatch, +): + class Service: + def approve_room_task(self, _room_id, **_kwargs): + return {"resolved": 1} + + db = tmp_path / "state.db" + pending = _pending(db) + monkeypatch.setattr( + approvals, + "complete_approval_command", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk busy")), + ) + + result = approvals.submit_approval( + db, + service=Service(), + command_id="approval-command-1", + pending=pending, + choice="once", + ) + + assert result["queued"] is True + assert result["applied"] is True + + +def test_one_pending_approval_cannot_queue_conflicting_decisions(tmp_path): + db = tmp_path / "state.db" + pending = _pending(db) + approvals.begin_approval_command( + db, + command_id="approval-command-1", + pending=pending, + choice="once", + ) + + with pytest.raises(approvals.MessagingApprovalError, match="different decision"): + approvals.begin_approval_command( + db, + command_id="approval-command-2", + pending=pending, + choice="deny", + ) + + +def test_stale_pending_approvals_and_commands_expire(tmp_path, monkeypatch): + db = tmp_path / "state.db" + now = [1_000_000.0] + monkeypatch.setattr(approvals.time, "time", lambda: now[0]) + pending = _pending(db) + approvals.begin_approval_command( + db, + command_id="approval-command-1", + pending=pending, + choice="once", + ) + + now[0] += approvals.PENDING_APPROVAL_TTL_SECONDS + 1 + + assert approvals.list_pending_approvals(db, room_id="room-1") == [] + assert approvals.list_pending_approval_commands(db, room_id="room-1") == [] + assert approvals.approval_command( + db, + command_id="approval-command-1", + ) is None + + approvals.persist_pending_approval( + db, + room_id="room-2", + member_id="member-2", + action=_action(task_id="task-2", request_id="request-2"), + ) + assert approvals.approval_command( + db, + command_id="approval-command-1", + ) is None + + +def test_pending_approval_journal_has_a_per_room_cap(tmp_path, monkeypatch): + db = tmp_path / "state.db" + monkeypatch.setattr(approvals, "MAX_PENDING_COMMANDS_PER_ROOM", 2) + for number in range(1, 3): + pending = approvals.persist_pending_approval( + db, + room_id="room-1", + member_id=f"member-{number}", + action=_action( + task_id=f"task-{number}", + request_id=f"request-{number}", + ), + ) + approvals.begin_approval_command( + db, + command_id=f"approval-command-{number}", + pending=pending, + choice="once", + ) + + third = approvals.persist_pending_approval( + db, + room_id="room-1", + member_id="member-3", + action=_action(task_id="task-3", request_id="request-3"), + ) + with pytest.raises(approvals.MessagingApprovalError, match="Too many"): + approvals.begin_approval_command( + db, + command_id="approval-command-3", + pending=third, + choice="once", + ) + + +def test_completed_receipts_do_not_consume_the_pending_cap(tmp_path, monkeypatch): + db = tmp_path / "state.db" + monkeypatch.setattr(approvals, "MAX_PENDING_COMMANDS_PER_ROOM", 2) + for number in range(1, 4): + pending = approvals.persist_pending_approval( + db, + room_id="room-1", + member_id=f"member-{number}", + action=_action( + task_id=f"task-{number}", + request_id=f"request-{number}", + ), + ) + command_id = f"approval-command-{number}" + approvals.begin_approval_command( + db, + command_id=command_id, + pending=pending, + choice="once", + ) + approvals.complete_approval_command( + db, + command_id=command_id, + result="Approved once.", + ) + + assert approvals.approval_command( + db, + command_id="approval-command-1", + )["state"] == "completed" + assert approvals.approval_command( + db, + command_id="approval-command-3", + )["state"] == "completed" + + +def test_multiple_approvals_require_an_explicit_number(tmp_path): + db = tmp_path / "state.db" + first = _pending(db) + second = approvals.persist_pending_approval( + db, + room_id="room-1", + member_id="member-2", + action=_action(task_id="task-2", request_id="request-2"), + ) + pending = approvals.list_pending_approvals(db, room_id="room-1") + + with pytest.raises(approvals.MessagingApprovalError, match="Choose"): + approvals.select_pending_approval(pending) + assert approvals.select_pending_approval( + pending, + approvals.approval_reference(first), + )[1] == first + assert approvals.select_pending_approval( + pending, + approvals.approval_reference(second), + )[1] == second + + +def test_approval_code_does_not_retarget_after_list_reordering(tmp_path): + db = tmp_path / "state.db" + first = _pending(db) + second = approvals.persist_pending_approval( + db, + room_id="room-1", + member_id="member-2", + action=_action(task_id="task-2", request_id="request-2"), + ) + reference = approvals.approval_reference(second) + approvals.clear_pending_approval( + db, + room_id="room-1", + member_id=str(first["member_id"]), + ) + reordered = approvals.list_pending_approvals(db, room_id="room-1") + + assert approvals.select_pending_approval(reordered, reference)[1] == second + with pytest.raises(approvals.MessagingApprovalError, match="approval code"): + approvals.select_pending_approval(reordered, "2") + + +def test_single_text_approval_also_requires_its_stable_code(tmp_path): + db = tmp_path / "state.db" + pending = _pending(db) + + with pytest.raises(approvals.MessagingApprovalError, match="approval code"): + approvals.select_pending_approval([pending]) + assert approvals.select_pending_approval( + [pending], + approvals.approval_reference(pending), + )[1] == pending + + +def test_desktop_hosted_approval_names_the_supported_surface(tmp_path): + class Backend: + db_path = tmp_path / "state.db" + service = None + + with pytest.raises( + approvals.MessagingApprovalError, + match="Approve or deny the command there", + ): + approvals.submit_room_approval( + Backend(), + {"room_id": "classic-room", "_room_mode": "desktop"}, + command_id="approval-command-1", + choice="once", + ) + + with pytest.raises( + approvals.MessagingApprovalError, + match="owner chat connected to the device running", + ): + approvals.submit_room_approval( + Backend(), + {"room_id": "remote-room", "_room_mode": "remote"}, + command_id="approval-command-2", + choice="once", + ) + + +def test_picker_tokens_are_short_and_bound_to_the_current_request(tmp_path): + db = tmp_path / "state.db" + pending = [_pending(db)] + room = {"room_id": "room-1", "members": []} + choices = approvals.approval_picker_choices(room, pending) + + assert all(len(choice["value"].encode()) <= 64 for choice in choices) + index, choice, request_id = approvals.resolve_approval_picker_choice( + room, + pending, + choices[0]["value"], + ) + assert (index, choice, request_id) == (1, "once", "request-1") + + changed = [{**pending[0], "task_id": "task-2"}] + with pytest.raises(approvals.MessagingApprovalError, match="changed"): + approvals.resolve_approval_picker_choice( + room, + changed, + choices[0]["value"], + ) + + +def test_approval_display_neutralizes_markup_shaped_bot_and_command_text(tmp_path): + db = tmp_path / "state.db" + pending = approvals.persist_pending_approval( + db, + room_id="room-1", + member_id="member-1", + action=_action( + approval={ + "description": "**Approve** [open](https://example.test) @all", + "command": "rm -rf /", + "choices": ["once", "deny"], + } + ), + ) + room = { + "room_id": "room-1", + "members": [ + {"member_id": "member-1", "display_name": "[Admin](url) @all"} + ], + } + + rendered = approvals.format_pending_approvals( + type("Service", (), {"status": lambda *_args: {"pending_actions": [pending]}})(), + room, + room_reference="1", + ) + choices = approvals.approval_picker_choices(room, [pending]) + + assert "[Admin](url)" not in rendered + assert "@all" not in rendered + assert "**Approve**" not in rendered + assert "[Admin](url) @all" in rendered + assert "Command: rm -rf /" in rendered + assert choices[0]["label"].startswith( + "✓ 1. Approve once · [Admin](url) @all · member-1" + ) + title = approvals.format_approval_picker_title(room, [pending]) + assert "**Approve** [open](https://example.test) @all" in title + + +def test_native_picker_title_keeps_the_complete_bounded_action(tmp_path): + db = tmp_path / "state.db" + action = "Run harmless preview. " + ("check output; " * 15) + "DELETE ALL DATA" + pending = approvals.persist_pending_approval( + db, + room_id="room-1", + member_id="member-1", + action=_action( + approval={ + "description": action, + "command": "", + "choices": ["once", "deny"], + } + ), + ) + + title = approvals.format_approval_picker_title( + {"room_id": "room-1", "members": []}, + [pending], + ) + + assert action in title + rendered = approvals.format_pending_approvals( + type("Service", (), {"status": lambda *_args: {"pending_actions": [pending]}})(), + {"room_id": "room-1", "members": []}, + room_reference="1", + ) + assert "DELETE ALL DATA" in rendered + + +def test_native_buttons_distinguish_duplicate_names_with_index_and_handle(tmp_path): + db = tmp_path / "state.db" + first = _pending(db) + second = approvals.persist_pending_approval( + db, + room_id="room-1", + member_id="member-2", + action=_action(task_id="task-2", request_id="request-2"), + ) + room = { + "room_id": "room-1", + "members": [ + {"member_id": "member-1", "display_name": "Reviewer", "handle": "alpha"}, + {"member_id": "member-2", "display_name": "Reviewer", "handle": "beta"}, + ], + } + + labels = [ + choice["label"] + for choice in approvals.approval_picker_choices(room, [first, second]) + ] + + assert labels == [ + "✓ 1. Approve once · Reviewer · @alpha", + "✕ 1. Deny · Reviewer · @alpha", + "✓ 2. Approve once · Reviewer · @beta", + "✕ 2. Deny · Reviewer · @beta", + ] + + long_room = { + **room, + "members": [ + {"member_id": "member-1", "display_name": "R" * 80, "handle": "alpha"}, + {"member_id": "member-2", "display_name": "R" * 80, "handle": "beta"}, + ], + } + long_labels = [ + choice["label"] + for choice in approvals.approval_picker_choices(long_room, [first, second]) + ] + assert long_labels[0].endswith("@alpha") + assert long_labels[2].endswith("@beta") + assert long_labels[0] != long_labels[2] diff --git a/tests/gateway/test_hosted_room_messaging_security.py b/tests/gateway/test_hosted_room_messaging_security.py new file mode 100644 index 0000000000000..c2940aa8dab0c --- /dev/null +++ b/tests/gateway/test_hosted_room_messaging_security.py @@ -0,0 +1,969 @@ +"""Authorization, dispatch, and lifecycle tests for Group Chat messaging.""" + +from __future__ import annotations + +import hashlib +import sqlite3 +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway import hosted_room_driver, hosted_room_messaging, hosted_rooms +from gateway.config import HomeChannel, Platform, PlatformConfig +from gateway.hosted_room_messaging import ( + MessagingRoomBackend, + RoomControlError, + messaging_actor, + messaging_event_id, + relay_provenance_is_unknown, +) +from gateway.session import SessionSource +from hermes_cli.commands import resolve_command +from tests.gateway.test_hosted_room_messaging import ( + _FakeService, + _HoldingRPC, + _ImmediateRPC, + _TestHostedRoomService, + _event, + _runner, + _seed_rooms, + _wait_for, +) + + +def test_messaging_identity_is_stable_private_and_edit_safe(): + first = _event("/group 1 send hello", platform=Platform.TELEGRAM) + first.platform_update_id = 123 + second = _event("/group 1 send edited", platform=Platform.TELEGRAM) + second.platform_update_id = 124 + actor = messaging_actor(first, gateway_id="install:test-gateway") + assert actor["display_name"] == "Display Name via Telegram" + assert "user-1" not in actor["id"] + assert messaging_event_id(first) == messaging_event_id(second) + assert messaging_event_id(first) == messaging_event_id(first) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", [Platform.SLACK, Platform.MATRIX]) +async def test_dm_label_without_one_to_one_proof_cannot_control_group_chats( + tmp_path, monkeypatch, platform +): + service = _FakeService(tmp_path / "state.db") + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner(platform=platform)._handle_rooms_command( + _event("/group", platform=platform, is_one_to_one=None) + ) + + assert result == ( + "Group Chat controls are private. Use your authorized one-to-one " + "Hermes chat." + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "platform", + [Platform.WHATSAPP_CLOUD, Platform.EMAIL, Platform.SMS], +) +async def test_native_distinct_dm_proves_private_owner_surface(tmp_path, monkeypatch, platform): + service = _FakeService(tmp_path / "state.db") + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner( + platform=platform, + extra={"allow_admin_from": ["user-1"]}, + )._handle_rooms_command( + _event( + "/group list", + platform=platform, + is_one_to_one=None, + ) + ) + + assert result.startswith("👥 **No Group Chats yet**") + + +@pytest.mark.asyncio +async def test_edited_message_cannot_start_group_chat_work(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group 1 send changed", message_id="message-1") + event.source.message_is_edit = True + + result = await _runner()._handle_rooms_command(event) + + assert result == "Edited messages can’t run Group Chat commands. Send a new message." + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_mutating_group_chat_commands_have_a_per_sender_rate_limit( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + runner = _runner() + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + monkeypatch.setattr("gateway.group_chat_slash._GROUP_CHAT_MUTATION_RATE_LIMIT", 2) + + first = await runner._handle_rooms_command( + _event("/group 1 send first", message_id="rate-1") + ) + second = await runner._handle_rooms_command( + _event("/group 1 send second", message_id="rate-2") + ) + limited = await runner._handle_rooms_command( + _event("/group 1 send third", message_id="rate-3") + ) + + assert first.startswith("Queued in") + assert second.startswith("Queued in") + assert limited == "Too many Group Chat commands. Wait a moment and try again." + assert len(service.sent) == 2 + + +@pytest.mark.parametrize( + "raw_message", + [ + {"timestamp_ms": 1770000000000}, + {"trigger_id": "slack-trigger-1"}, + SimpleNamespace(id="discord-interaction-1"), + ], +) +def test_real_channel_raw_ids_are_stable_without_normalized_message_id(raw_message): + event = _event("/group 1 send hello") + event.message_id = None + event.source.message_id = None + event.raw_message = raw_message + assert messaging_event_id(event) == messaging_event_id(event) + + +def test_mutation_fails_closed_without_a_transport_redelivery_id(): + event = _event("/group 1 send hello") + event.message_id = None + event.source.message_id = None + with pytest.raises(RoomControlError, match="stable message ID"): + messaging_event_id(event) + + +def test_signal_group_idempotency_includes_sender_identity(): + first = _event("/group 1 send hello", user_id="sender-a") + second = _event("/group 1 send hello", user_id="sender-b") + for event in (first, second): + event.message_id = None + event.source.message_id = None + event.raw_message = {"timestamp_ms": 1770000000000} + assert messaging_event_id(first) != messaging_event_id(second) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", [p for p in Platform if p is not Platform.LOCAL]) +async def test_send_handler_is_shared_by_every_gateway_channel( + tmp_path, monkeypatch, platform +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event( + "/group 1 send hello from messaging", + platform=platform, + message_id=f"message-{platform.value}", + ) + runner = _runner(platform=platform) + result = await runner._handle_room_command(event) + rooms_command = f"{runner._typed_command_prefix_for(platform)}group" + assert result == f"Queued in Release room. Check: `{rooms_command} 1`." + assert service.sent[-1]["payload"]["text"] == "hello from messaging" + platform_label = platform.value.replace("_", " ").title() + assert service.sent[-1]["actor"]["display_name"] == ( + f"Display Name via {platform_label}" + ) + + +@pytest.mark.asyncio +async def test_entity_first_group_send_is_dispatched(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_rooms_command( + _event("/group 1 send hello from Signal", message_id="entity-first-1") + ) + + assert result == "Queued in Release room. Check: `/group 1`." + assert service.sent[-1]["payload"]["text"] == "hello from Signal" + + +@pytest.mark.asyncio +async def test_send_rejects_attachments_instead_of_silently_dropping_them( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + result = await _runner()._handle_room_command( + _event("/group 1 send inspect this", media=True) + ) + assert "Attachments from messaging chats aren’t supported yet" in result + assert service.sent == [] + + ignored = _event("/group 1 send inspect this") + ignored.source.message_had_attachments = True + result = await _runner()._handle_room_command(ignored) + assert "Attachments from messaging chats aren’t supported yet" in result + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_bot_authored_controls_are_rejected_to_prevent_bridge_loops( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + result = await _runner(platform=Platform.DISCORD)._handle_room_command( + _event( + "/group 1 send repeat this", + platform=Platform.DISCORD, + is_bot=True, + ) + ) + assert result == "Group Chat controls are only available to people." + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_raw_webhook_bot_marker_is_rejected_even_without_source_flag( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group 1 send repeat this") + event.raw_message = {"subtype": "bot_message", "bot_id": "B123"} + result = await _runner()._handle_rooms_command(event) + assert result == "Group Chat controls are only available to people." + assert service.sent == [] + + +def test_relay_and_session_roundtrip_preserve_bot_provenance(): + from gateway.relay.ws_transport import _event_from_wire + + source = SessionSource( + platform=Platform.DISCORD, + chat_id="chat-1", + user_id="bot-1", + is_bot=True, + ) + assert SessionSource.from_dict(source.to_dict()).is_bot is True + wire_source = source.to_dict() + wire_source["message_is_edit"] = False + relayed = _event_from_wire( + { + "text": "/group", + "message_type": "command", + "source": wire_source, + } + ) + assert relayed.source.is_bot is True + assert relay_provenance_is_unknown(relayed) is False + + +def test_legacy_relay_without_author_classification_fails_closed(): + from gateway.relay.ws_transport import _event_from_wire + + relayed = _event_from_wire( + { + "text": "/group", + "message_type": "command", + "source": { + "platform": "discord", + "chat_id": "chat-1", + "chat_type": "dm", + "user_id": "user-1", + }, + } + ) + assert relay_provenance_is_unknown(relayed) is True + + +@pytest.mark.parametrize( + ("platform", "verified", "expected"), + [ + ("signal", None, True), + ("telegram", None, True), + ("whatsapp", None, True), + ("slack", None, None), + ("slack", True, True), + ("matrix", False, False), + ], +) +def test_authenticated_relay_preserves_one_to_one_privacy_proof( + platform, + verified, + expected, +): + from gateway.relay.ws_transport import _event_from_wire + + source = { + "platform": platform, + "chat_id": "private-chat", + "chat_type": "dm", + "user_id": "user-1", + "is_bot": False, + "message_is_edit": False, + } + if verified is not None: + source["one_to_one_verified"] = verified + relayed = _event_from_wire( + { + "text": "/group", + "message_type": "command", + "source": source, + } + ) + + assert relayed.source.is_one_to_one is expected + assert relay_provenance_is_unknown(relayed) is False + + +@pytest.mark.asyncio +async def test_classified_relay_dm_with_explicit_admin_can_control_group_chats( + tmp_path, + monkeypatch, +): + from gateway.relay.ws_transport import _event_from_wire + + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: service, + ) + event = _event_from_wire( + { + "text": "/group list", + "message_type": "command", + "source": { + "platform": "signal", + "chat_id": "chat-signal", + "chat_type": "dm", + "user_id": "user-1", + "is_bot": False, + "message_is_edit": False, + }, + } + ) + + result = await _runner(extra={"allow_admin_from": ["user-1"]})._handle_rooms_command(event) + + assert result.startswith("👥 **Group Chats**") + + +@pytest.mark.asyncio +async def test_stop_requires_admin_when_operator_enabled_slash_gating( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + extra = { + "allow_admin_from": ["admin"], + "user_allowed_commands": ["groups"], + } + result = await _runner(extra=extra)._handle_room_command( + _event("/group 1 stop", user_id="member") + ) + assert result.startswith("This chat can’t control Group Chats") + assert service.stopped == [] + + result = await _runner(extra=extra)._handle_room_command( + _event("/group 1 stop", user_id="admin", message_id="stop-1") + ) + assert result == ( + "Stop requested for Release room. Active work will stop safely. " + "Check: `/group 1`." + ) + assert service.stopped[0][0] == "release-room" + + +@pytest.mark.asyncio +async def test_even_an_admin_cannot_expose_room_history_in_a_shared_chat( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group list", user_id="admin", chat_type="group") + + result = await _runner(extra={"group_allow_admin_from": ["admin"]})._handle_rooms_command(event) + + assert result == ( + "Group Chat controls are private. Use your authorized one-to-one " + "Hermes chat." + ) + assert "Release room" not in result + + +@pytest.mark.asyncio +async def test_room_history_and_mutation_require_an_explicit_admin( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={"allow_from": ["user-1"]}) + runner._is_user_authorized_for_source = lambda _source: True + denial = "This chat can’t control Group Chats" + assert denial in await runner._handle_rooms_command(_event("/group")) + assert denial in await runner._handle_room_command( + _event("/group 1 send hello") + ) + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_home_dm_controls_rooms_without_duplicate_admin_list( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "user-1") + runner = _runner(extra={}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + + listing = await runner._handle_rooms_command(_event("/group list")) + assert listing.startswith("👥 **Group Chats**") + result = await runner._handle_room_command( + _event("/group 1 send hello", message_id="home-send-1") + ) + assert result.startswith("Queued in Release room") + assert service.sent[0]["payload"]["text"] == "hello" + + +@pytest.mark.asyncio +async def test_explicit_home_group_allows_only_the_selecting_operator( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "user-1") + runner = _runner(extra={"allow_from": ["user-1"]}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home team", + user_id="user-1", + ) + event = _event( + "/group 1 send hello from home", + message_id="home-group-send-1", + chat_type="group", + is_one_to_one=False, + ) + + result = await runner._handle_room_command(event) + + assert result.startswith("Queued in Release room") + assert service.sent[0]["payload"]["text"] == "hello from home" + + +@pytest.mark.asyncio +async def test_non_home_dm_still_requires_explicit_admin(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={}) + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="different-chat", + name="Home", + ) + + result = await runner._handle_room_command( + _event("/group 1 send hello", message_id="other-send-1") + ) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_shared_home_dm_does_not_auto_promote_an_allowed_user( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={"allow_from": ["user-1", "user-2"]}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + + result = await runner._handle_room_command( + _event("/group 1 send hello", message_id="shared-send-1") + ) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("allowed_users", "allow_all"), + [ + ("user-1,user-2", ""), + ("user-1", "true"), + ], +) +async def test_builtin_platform_grants_fail_closed_without_registry( + tmp_path, + monkeypatch, + allowed_users, + allow_all, +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + monkeypatch.setenv("SIGNAL_ALLOWED_USERS", allowed_users) + monkeypatch.setenv("SIGNAL_ALLOW_ALL_USERS", allow_all) + runner = _runner(extra={}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + + result = await runner._handle_room_command( + _event("/group 1 send hello", message_id="grant-send-1") + ) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_routed_profile_uses_transport_principals_for_owner_census( + tmp_path, monkeypatch +): + from contextlib import contextmanager + + from gateway import authz_mixin, run as gateway_run + + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + active_scope = {"name": "routed"} + + @contextmanager + def fake_profile_scope(path): + previous = active_scope["name"] + active_scope["name"] = Path(path).name + try: + yield + finally: + active_scope["name"] = previous + + def fake_auth_env(name, default=""): + if name == "SIGNAL_ALLOWED_USERS": + return ( + "user-1,user-2" + if active_scope["name"] == "transport" + else "user-1" + ) + return default + + monkeypatch.setattr(gateway_run, "_profile_runtime_scope", fake_profile_scope) + monkeypatch.setattr(authz_mixin, "_auth_env", fake_auth_env) + runner = _runner(extra={}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + event = _event("/group 1 send hello", message_id="routed-send-1") + event.source.profile = "worker" + event.source._authorization_profile_home = Path("/transport") + + result = await runner._handle_room_command(event) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_secondary_adapter_allowlist_owns_the_owner_census( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={"allow_from": ["user-1"]}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + adapter = SimpleNamespace( + config=PlatformConfig(extra={"allow_from": ["user-1", "user-2"]}) + ) + runner._profile_adapters = {"worker": {Platform.SIGNAL: adapter}} + runner.adapters = {} + event = _event("/group 1 send hello", message_id="secondary-send-1") + event.source.profile = "worker" + event.source._transport_adapter_ref = lambda: adapter + + result = await runner._handle_room_command(event) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_registered_adapter_without_visible_allowlist_fails_closed( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={"allow_from": ["user-1"]}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + adapter = SimpleNamespace(config=PlatformConfig(extra={})) + runner._profile_adapters = {"worker": {Platform.SIGNAL: adapter}} + runner.adapters = {} + event = _event("/group 1 send hello", message_id="opaque-send-1") + event.source.profile = "worker" + event.source._transport_adapter_ref = lambda: adapter + + result = await runner._handle_room_command(event) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_relayed_home_dm_requires_explicit_admin(tmp_path, monkeypatch): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + runner = _runner(extra={"allow_from": ["user-1"]}) + runner._is_user_authorized_for_source = lambda _source: True + runner.config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id="chat-signal", + name="Home", + ) + event = _event("/group 1 send hello", message_id="relay-send-1") + event.source.delivered_via_upstream_relay = True + event.metadata = { + "relay_author_classified": True, + "relay_edit_classified": True, + } + + result = await runner._handle_room_command(event) + assert result.startswith("This chat can’t control Group Chats") + assert service.sent == [] + + +@pytest.mark.asyncio +async def test_busy_dispatch_runs_room_control_without_touching_main_agent(): + runner = _runner() + runner._handle_rooms_command = AsyncMock(return_value="room-dispatched") + event = _event("/group 1 send hello") + result = await runner._dispatch_busy_slash_command( + event, + resolve_command("group"), + "session-key", + event.source, + ) + assert result == "room-dispatched" + runner._handle_rooms_command.assert_awaited_once_with(event) + + +@pytest.mark.asyncio +async def test_real_service_persists_server_owned_messaging_actor(tmp_path, monkeypatch): + service = _TestHostedRoomService(tmp_path / "state.db") + service.create_room( + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", + lambda: MessagingRoomBackend(db_path=service.db_path, service=service), + ) + event = _event( + "/group 1 send @ops inspect the release", + platform=Platform.SIGNAL, + ) + result = await _runner()._handle_room_command(event) + assert result == "Queued in Release room. Check: `/group 1`." + delta = hosted_rooms.read_events( + service.db_path, + room_id="release-room", + since_seq=0, + limit=20, + ) + user_event = next(row for row in delta["events"] if row["kind"] == "message.user") + assert user_event["actor"]["display_name"] == "Display Name via Signal" + assert user_event["actor"]["id"].startswith("messaging:signal:") + assert "user-1" not in user_event["actor"]["id"] + + +def test_cross_process_store_wakes_owner_without_desktop_transport(tmp_path): + service = _TestHostedRoomService(tmp_path / "state.db") + service.rpc = _ImmediateRPC() + service.runtime.rpc = service.rpc + service.create_room( + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + messaging_process = MessagingRoomBackend(db_path=service.db_path) + first_event = _event( + "/group 1 send @ops inspect the release", + platform=Platform.WHATSAPP, + message_id="message-1", + ) + second_event = _event( + "/group 1 send @ops check the notes", + platform=Platform.WHATSAPP, + message_id="message-2", + ) + for event, text in ( + (first_event, "@ops inspect the release"), + (second_event, "@ops check the notes"), + ): + event_id = messaging_event_id(event) + messaging_process.send( + room_id="release-room", + event_id=event_id, + payload={"text": text, "thread_id": event_id}, + actor=messaging_actor(event, gateway_id="install:test-gateway"), + ) + service.start() + try: + _wait_for( + lambda: sum( + row["kind"] == "message.member" + for row in hosted_rooms.read_events( + service.db_path, + room_id="release-room", + since_seq=0, + limit=40, + )["events"] + ) + == 2 + ) + finally: + assert service.stop(timeout=1.0) + + events = hosted_rooms.read_events( + service.db_path, + room_id="release-room", + since_seq=0, + limit=40, + )["events"] + assert [row["kind"] for row in events[:2]] == ["message.user", "message.user"] + assert sum(row["kind"] == "message.member" for row in events) == 2 + assert events[0]["actor"]["display_name"] == "Display Name via Whatsapp" + + +def test_cross_process_stop_cancels_durable_queued_work(tmp_path): + service = _TestHostedRoomService(tmp_path / "state.db") + service.create_room( + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + service.send( + room_id="release-room", + event_id="user-1", + payload={"text": "@ops inspect", "thread_id": "thread-1"}, + ) + messaging_process = MessagingRoomBackend(db_path=service.db_path) + assert messaging_process.stop_room("release-room", cancel_id="stop-1") == 1 + assert messaging_process.stop_room("release-room", cancel_id="stop-1") == 1 + service.prepare_room(service.bindings()[0]) + assert messaging_process.status("release-room")["working"] is False + tasks = hosted_room_driver.list_tasks(service.db_path, room_id="release-room") + assert [task["status"] for task in tasks] == ["cancelled"] + + +def test_cross_process_stop_cancels_deferred_work(tmp_path): + service = _TestHostedRoomService(tmp_path / "state.db") + service.create_room( + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + service.send( + room_id="release-room", + event_id="user-1", + payload={"text": "@ops inspect", "thread_id": "thread-1"}, + ) + with sqlite3.connect(service.db_path) as conn: + conn.execute( + """UPDATE hosted_room_driver_tasks + SET status='deferred', execution_generation=1 + WHERE room_id='release-room'""" + ) + conn.commit() + + messaging_process = MessagingRoomBackend(db_path=service.db_path) + assert messaging_process.stop_room("release-room", cancel_id="stop-deferred") == 1 + task = hosted_room_driver.list_tasks(service.db_path, room_id="release-room")[0] + assert task["status"] == "stopping" + assert task["cancel_id"] == "stop-deferred" + + +def test_cross_process_send_is_idempotent_on_transport_redelivery( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + hosted_rooms, + "local_authority_gateway_id", + lambda: "install:test-gateway", + ) + db, room, _ = _seed_rooms(tmp_path) + messaging_process = MessagingRoomBackend(db_path=db) + event = _event("/group 1 send hello", platform=Platform.TELEGRAM) + event_id = messaging_event_id(event) + payload = {"text": "hello", "thread_id": event_id} + actor = messaging_actor(event, gateway_id="install:test-gateway") + first = messaging_process.send( + room_id=room["room_id"], + event_id=event_id, + payload=payload, + actor=actor, + ) + second = messaging_process.send( + room_id=room["room_id"], + event_id=event_id, + payload=payload, + actor=actor, + ) + assert first["seq"] == second["seq"] == 1 + assert second["idempotent"] is True + delta = hosted_rooms.read_events( + db, room_id=room["room_id"], since_seq=0, limit=20 + ) + assert len(delta["events"]) == 1 + + +def test_cross_process_stop_is_acknowledged_by_owner_for_running_work(tmp_path): + service = _TestHostedRoomService(tmp_path / "state.db") + service.rpc = _HoldingRPC() + service.runtime.rpc = service.rpc + service.create_room( + room_id="release-room", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + messaging_process = MessagingRoomBackend(db_path=service.db_path) + event = _event("/group 1 send inspect") + messaging_process.send( + room_id="release-room", + event_id=messaging_event_id(event), + payload={"text": "inspect", "thread_id": messaging_event_id(event)}, + actor=messaging_actor(event, gateway_id="install:test-gateway"), + ) + service.start() + try: + _wait_for( + lambda: any( + task["status"] == "running" + for task in hosted_room_driver.list_tasks( + service.db_path, room_id="release-room" + ) + ) + ) + assert messaging_process.stop_room("release-room", cancel_id="stop-1") == 1 + _wait_for( + lambda: hosted_room_driver.list_tasks( + service.db_path, room_id="release-room" + )[0]["status"] + == "cancelled" + ) + finally: + assert service.stop(timeout=1.0) + + assert len(service.rpc.interrupts) == 1 + tasks = hosted_room_driver.list_tasks(service.db_path, room_id="release-room") + assert len(tasks) == 1 + assert tasks[0]["status"] == "cancelled" diff --git a/tests/gateway/test_hosted_room_peer.py b/tests/gateway/test_hosted_room_peer.py index f2fa83ca652b0..0cbba20efb53e 100644 --- a/tests/gateway/test_hosted_room_peer.py +++ b/tests/gateway/test_hosted_room_peer.py @@ -312,6 +312,45 @@ def test_room_grant_fails_closed_for_tamper_expiry_and_permission(): ) +def test_expired_status_grant_can_only_authenticate_idempotent_revocation(): + dispatch = _dispatch() + token = issue_room_grant( + SECRET, + grant_id="grant-expired", + room_id=dispatch.room_id, + home_install_id=dispatch.home_install_id, + authority_gateway_id=dispatch.authority_gateway_id, + authority_epoch=dispatch.authority_epoch, + member_id=dispatch.member_id, + target_install_id=dispatch.target_install_id, + target_profile=dispatch.target_profile, + execution_policy_digest=dispatch.execution_policy_digest, + permissions=("status",), + issued_at=100, + ttl_seconds=10, + status_expires_at=120, + ) + + with pytest.raises(HostedRoomGrantError, match="expired"): + decode_room_grant(SECRET, token, permission="status", now=121) + claims = decode_room_grant( + SECRET, + token, + permission="status", + now=121, + allow_expired_for_revocation=True, + ) + assert claims["grant_id"] == "grant-expired" + with pytest.raises(HostedRoomGrantError, match="only.*revocation"): + decode_room_grant( + SECRET, + token, + permission="run", + now=121, + allow_expired_for_revocation=True, + ) + + def test_link_selection_prefers_safe_direct_then_overlay_then_relay_then_pull(): selected = select_room_link( [ diff --git a/tests/gateway/test_hosted_rooms.py b/tests/gateway/test_hosted_rooms.py index 1c252a8b1b73b..cc5d96cb8664b 100644 --- a/tests/gateway/test_hosted_rooms.py +++ b/tests/gateway/test_hosted_rooms.py @@ -742,11 +742,17 @@ def test_room_log_pages_are_bounded_by_serialized_event_bytes(tmp_path, monkeypa payload={"text": "x" * 180, "index": index}, ) - one_event = rooms.read_events(db, room_id="room-1", limit=1) - budget = len( - json.dumps(one_event, ensure_ascii=False, separators=(",", ":")).encode( - "utf-8" + single_event_pages = [ + rooms.read_events(db, room_id="room-1", since_seq=cursor, limit=1) + for cursor in range(4) + ] + budget = max( + len( + json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) ) + for page in single_event_pages ) + 1 monkeypatch.setattr(rooms, "MAX_LOG_PAGE_BYTES", budget) @@ -1107,6 +1113,66 @@ def read_then_prune(*args, **kwargs): assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0 +def test_terminal_retry_reconstructs_from_compacted_thread_transcript(tmp_path): + db = tmp_path / "state.db" + _create(db) + _append( + db, + room_id="room-1", + event_id="user-retry", + kind="message.user", + actor=USER, + payload={"text": "retry me", "thread_id": "thread-1"}, + now=11, + ) + _append( + db, + room_id="room-1", + event_id="deferred-1", + kind="turn.deferred", + actor={"kind": "gateway", "id": "gateway-a"}, + payload={ + "discussion_event_id": "user-retry", + "execution_generation": 1, + "member_id": "bot-1", + "seen_through_seq": 1, + "task_id": "task-retry", + "thread_id": "thread-1", + }, + authority_gateway_id="gateway-a", + authority_epoch=1, + now=12, + ) + _append( + db, + room_id="room-1", + event_id="activity-1", + kind="room.activity", + actor={"kind": "gateway", "id": "gateway-a"}, + payload={ + "discussion_event_id": "user-retry", + "reason_code": "silent_round", + "status": "settled", + "thread_id": "thread-1", + }, + authority_gateway_id="gateway-a", + authority_epoch=1, + now=13, + ) + checkpoint = HostedRoomPolicyCheckpoint(db) + checkpoint.sync(room_id="room-1", latest_seq=3) + + with sqlite3.connect(db) as conn: + assert conn.execute( + """SELECT COUNT(*) FROM hosted_room_policy_events + WHERE room_id='room-1'""" + ).fetchone()[0] == 0 + events = checkpoint.events_for_task(room_id="room-1", source_event_seq=1) + assert [(event["seq"], event["kind"]) for event in events] == [ + (1, "message.user") + ] + + def test_pre_actor_draft_database_migrates_with_explicit_legacy_identity(tmp_path): db = tmp_path / "state.db" _create_pre_actor_database(db) diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index 078d787d43cf9..d7a269a89473d 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -588,8 +588,31 @@ async def _fake_handle_message(event): adapter._fetch_attachment = AsyncMock(return_value=(fetch_path, fetch_ext)) await adapter._handle_envelope(envelope) assert dispatched, "_handle_envelope did not dispatch any event" + assert dispatched[0].source.is_one_to_one is True return dispatched[0] + @pytest.mark.asyncio + async def test_signal_edit_is_marked_for_command_replay_guard(self, monkeypatch): + envelope = _make_dm_envelope( + sender="+15559876543", + text="/group 1 send changed", + attachments=[], + ) + data_message = envelope["envelope"].pop("dataMessage") + envelope["envelope"]["editMessage"] = {"dataMessage": data_message} + adapter = _make_signal_adapter(monkeypatch) + adapter._rpc, _ = _stub_rpc(None) + dispatched = [] + + async def _fake_handle_message(event): + dispatched.append(event) + + adapter.handle_message = _fake_handle_message + await adapter._handle_envelope(envelope) + + assert len(dispatched) == 1 + assert dispatched[0].source.message_is_edit is True + @pytest.mark.asyncio async def test_pdf_attachment_sets_document_type(self, monkeypatch): """A PDF attachment (application/pdf) must produce MessageType.DOCUMENT, not TEXT.""" @@ -994,6 +1017,9 @@ async def fake_handle(event): assert event.reply_to_text == "want to grab lunch?" assert event.reply_to_author_id == "other-author" assert event.reply_to_is_own_message is False + from gateway.hosted_room_messaging import messaging_event_id + + assert messaging_event_id(event) == messaging_event_id(event) @pytest.mark.asyncio diff --git a/tests/gateway/test_signal_format.py b/tests/gateway/test_signal_format.py index 0b8805e2e0ab0..4f20b92f1cd9e 100644 --- a/tests/gateway/test_signal_format.py +++ b/tests/gateway/test_signal_format.py @@ -187,7 +187,6 @@ def test_lone_asterisk(self): # Should not crash; any italic match would be a false positive assert "5" in text and "15" in text - # =========================================================================== # signal-markdown-strip-patch: core conversion pipeline # =========================================================================== @@ -255,4 +254,3 @@ def test_signal_does_not_support_editing(self, monkeypatch): monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") from gateway.platforms.signal import SignalAdapter assert SignalAdapter.SUPPORTS_MESSAGE_EDITING is False - diff --git a/tests/gateway/test_slash_access.py b/tests/gateway/test_slash_access.py index 82a7ad2d90c8f..e6df0978bdd0d 100644 --- a/tests/gateway/test_slash_access.py +++ b/tests/gateway/test_slash_access.py @@ -5,9 +5,11 @@ """ from __future__ import annotations -from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig from gateway.session import SessionSource from gateway.slash_access import ( + is_home_control_source, + is_home_dm_source, policy_for_source, policy_from_extra, ) @@ -126,3 +128,92 @@ def test_no_admin_list_for_dm_means_unrestricted_in_dm(self): assert grp_p.enabled is True assert grp_p.can_run("999", "stop") is False # gated + +class TestHomeDmSource: + def _config(self, *, user_id=None, scope_id=None): + return GatewayConfig( + platforms={ + Platform.TELEGRAM: PlatformConfig( + enabled=True, + home_channel=HomeChannel( + platform=Platform.TELEGRAM, + chat_id="home-chat", + name="Home", + user_id=user_id, + scope_id=scope_id, + ), + ) + } + ) + + def test_legacy_home_dm_matches_authenticated_sender(self): + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="dm", + user_id="owner", + ) + assert is_home_dm_source(self._config(), source) is True + + def test_home_group_never_inherits_owner_controls(self): + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="group", + user_id="owner", + ) + assert is_home_dm_source(self._config(), source) is False + + def test_home_group_matches_only_its_stored_operator(self): + config = self._config(user_id="owner") + owner = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="group", + user_id="owner", + ) + other = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="group", + user_id="other", + ) + assert is_home_control_source(config, owner) is True + assert is_home_control_source(config, other) is False + + def test_stored_identity_and_scope_must_match(self): + config = self._config(user_id="owner", scope_id="tenant-a") + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="dm", + user_id="other", + scope_id="tenant-a", + ) + assert is_home_dm_source(config, source) is False + wrong_scope = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="dm", + user_id="owner", + scope_id="tenant-b", + ) + assert is_home_dm_source(config, wrong_scope) is False + + def test_bot_and_other_dm_do_not_match(self): + config = self._config() + bot = SessionSource( + platform=Platform.TELEGRAM, + chat_id="home-chat", + chat_type="dm", + user_id="owner", + is_bot=True, + ) + other = SessionSource( + platform=Platform.TELEGRAM, + chat_id="other-chat", + chat_type="dm", + user_id="owner", + ) + assert is_home_dm_source(config, bot) is False + assert is_home_dm_source(config, other) is False diff --git a/tests/gateway/test_telegram_choice_picker.py b/tests/gateway/test_telegram_choice_picker.py new file mode 100644 index 0000000000000..29eba712565e4 --- /dev/null +++ b/tests/gateway/test_telegram_choice_picker.py @@ -0,0 +1,143 @@ +"""Telegram finite-choice pickers stay bound to one user and message.""" + +from __future__ import annotations + +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from plugins.platforms.telegram.adapter import TelegramAdapter + + +def _adapter(state): + adapter = object.__new__(TelegramAdapter) + adapter._choice_picker_state = {"chat-1": state} + adapter._is_callback_user_authorized = lambda *_args, **_kwargs: True + adapter.format_message = lambda text: text + return adapter + + +def _query(*, message_id=10, user_id="user-1"): + return SimpleNamespace( + answer=AsyncMock(), + edit_message_text=AsyncMock(), + from_user=SimpleNamespace(id=user_id, first_name="Owner"), + message=SimpleNamespace( + chat=SimpleNamespace(type="private"), + chat_id="chat-1", + message_id=message_id, + message_thread_id=None, + ), + ) + + +def _state(callback): + return { + "choices": [{"value": "12", "label": "Product launch"}], + "expires_at": time.monotonic() + 120, + "msg_id": 10, + "on_choice_selected": callback, + "requester_user_id": "user-1", + "session_key": "session-1", + } + + +@pytest.mark.asyncio +async def test_full_width_choices_render_one_room_per_row(monkeypatch): + from plugins.platforms.telegram import adapter as telegram_adapter + + monkeypatch.setattr( + telegram_adapter, + "InlineKeyboardButton", + lambda label, callback_data: {"label": label, "callback_data": callback_data}, + ) + monkeypatch.setattr( + telegram_adapter, + "InlineKeyboardMarkup", + lambda rows: SimpleNamespace(inline_keyboard=rows), + ) + adapter = object.__new__(TelegramAdapter) + adapter._bot = object() + adapter._choice_picker_state = {} + adapter._link_preview_kwargs = lambda: {} + adapter._reply_to_message_id_for_send = lambda *_args, **_kwargs: None + adapter._reply_to_mode = "none" + adapter._send_message_with_thread_fallback = AsyncMock( + return_value=SimpleNamespace(message_id=10) + ) + adapter._thread_kwargs_for_send = lambda *_args, **_kwargs: {} + adapter.format_message = lambda text: text + + result = await adapter.send_choice_picker( + chat_id="chat-1", + title="Group Chats", + choices=[ + {"value": "1", "label": "Release", "full_width": True}, + {"value": "2", "label": "Research", "full_width": True}, + ], + session_key="session-1", + on_choice_selected=AsyncMock(), + metadata={"requester_user_id": "user-1"}, + ) + + assert result.success is True + markup = adapter._send_message_with_thread_fallback.await_args.kwargs[ + "reply_markup" + ] + assert [len(row) for row in markup.inline_keyboard] == [1, 1] + assert adapter._choice_picker_state["chat-1"]["requester_user_id"] == "user-1" + + +@pytest.mark.asyncio +async def test_stale_keyboard_cannot_select_a_newer_picker(): + callback = AsyncMock(return_value="opened") + adapter = _adapter(_state(callback)) + query = _query(message_id=9) + + await adapter._handle_choice_picker_callback(query, "cp:0", "chat-1") + + callback.assert_not_awaited() + query.edit_message_text.assert_not_awaited() + assert "chat-1" in adapter._choice_picker_state + assert "expired" in query.answer.await_args.kwargs["text"] + + +@pytest.mark.asyncio +async def test_expired_picker_is_removed_without_running_callback(): + callback = AsyncMock(return_value="opened") + state = _state(callback) + state["expires_at"] = time.monotonic() - 1 + adapter = _adapter(state) + query = _query() + + await adapter._handle_choice_picker_callback(query, "cp:0", "chat-1") + + callback.assert_not_awaited() + assert "chat-1" not in adapter._choice_picker_state + + +@pytest.mark.asyncio +async def test_picker_rejects_another_authorized_user(): + callback = AsyncMock(return_value="opened") + adapter = _adapter(_state(callback)) + query = _query(user_id="user-2") + + await adapter._handle_choice_picker_callback(query, "cp:0", "chat-1") + + callback.assert_not_awaited() + assert "another user" in query.answer.await_args.kwargs["text"] + + +@pytest.mark.asyncio +async def test_current_owner_selection_edits_the_bound_message(): + callback = AsyncMock(return_value="💬 **Product launch**") + adapter = _adapter(_state(callback)) + query = _query() + + await adapter._handle_choice_picker_callback(query, "cp:0", "chat-1") + + callback.assert_awaited_once_with("chat-1", "12") + assert query.edit_message_text.await_args.kwargs["text"] == "💬 **Product launch**" + assert "chat-1" not in adapter._choice_picker_state diff --git a/tests/gateway/test_telegram_format.py b/tests/gateway/test_telegram_format.py index 2195179b28f20..82d8e6ac98e25 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -74,7 +74,6 @@ def test_plain_text_specials_escaped(self, adapter): assert "\\." in result assert "\\!" in result - # ========================================================================= # format_message - code blocks # ========================================================================= diff --git a/tests/gateway/test_telegram_reply_quote.py b/tests/gateway/test_telegram_reply_quote.py index 7150ed1dc8e43..37b4ceeac6be8 100644 --- a/tests/gateway/test_telegram_reply_quote.py +++ b/tests/gateway/test_telegram_reply_quote.py @@ -70,5 +70,18 @@ def test_native_partial_quote_used_as_reply_to_text(): assert event.reply_to_text == "Item B: rotate keys" assert event.reply_to_message_id == "42" + assert event.source.is_one_to_one is True + assert event.source.message_is_edit is False +def test_edited_telegram_message_is_marked_for_command_replay_guard(): + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + message = _make_message(text="/group 1 send changed") + message.edit_date = object() + + event = adapter._build_message_event(message, MessageType.TEXT) + + assert event.source.is_one_to_one is True + assert event.source.message_is_edit is True diff --git a/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py index 79e1dacb78e22..0d83abc427dc1 100644 --- a/tests/gateway/test_whatsapp_formatting.py +++ b/tests/gateway/test_whatsapp_formatting.py @@ -110,7 +110,6 @@ def test_already_whatsapp_italic(self): # Already-WhatsApp _italic_ passes through unchanged assert adapter.format_message("_italic_") == "_italic_" - # --------------------------------------------------------------------------- # MAX_MESSAGE_LENGTH tests # --------------------------------------------------------------------------- @@ -215,6 +214,13 @@ async def test_quoted_reply_metadata_is_preserved_in_raw_message(self): assert event.raw_message["quotedParticipant"] == "99999999999@s.whatsapp.net" assert event.raw_message["quotedRemoteJid"] == "15551234567@s.whatsapp.net" assert event.raw_message["hasQuotedMessage"] is True + assert event.source.is_one_to_one is True + + unknown = dict(data) + unknown.pop("isGroup") + unknown_event = await adapter._build_message_event(unknown) + assert unknown_event is not None + assert unknown_event.source.is_one_to_one is False # --------------------------------------------------------------------------- @@ -228,4 +234,3 @@ def test_whatsapp_streaming_follows_global(self): from gateway.display_config import resolve_display_setting # TIER_MEDIUM has streaming: None (follow global), not False assert resolve_display_setting({}, "whatsapp", "streaming") is None - diff --git a/tests/gateway/test_whatsapp_from_owner.py b/tests/gateway/test_whatsapp_from_owner.py index 1197c943c1283..69e9efd3552d7 100644 --- a/tests/gateway/test_whatsapp_from_owner.py +++ b/tests/gateway/test_whatsapp_from_owner.py @@ -77,6 +77,8 @@ def test_metadata_flag_set_when_payload_has_from_owner(): assert event.metadata.get("whatsapp_from_owner") is True assert event.text.startswith("[owner reply] ") assert event.text == "[owner reply] hi from the linked phone" + assert event.source.is_one_to_one is True + assert event.source.is_bot is False def test_from_owner_does_not_double_prefix_when_already_tagged(): @@ -92,4 +94,3 @@ def test_from_owner_does_not_double_prefix_when_already_tagged(): assert event.metadata.get("whatsapp_from_owner") is True assert event.text == "[owner reply] already tagged" - diff --git a/tests/tui_gateway/test_change_watcher.py b/tests/tui_gateway/test_change_watcher.py index 9c612c555f092..3dc86b1e1f248 100644 --- a/tests/tui_gateway/test_change_watcher.py +++ b/tests/tui_gateway/test_change_watcher.py @@ -240,6 +240,17 @@ def test_new_envelope_after_drain_fires_pending_again(watcher_home): ] +def test_desktop_room_command_signal_broadcasts_pending(watcher_home): + home, events = watcher_home + signal = home / "desktop_room_mailbox.pending" + server._broadcast_watched_changes(now=0.0) + + signal.write_text("1") + server._broadcast_watched_changes(now=10.0) + + assert ("desktop_rooms.commands.pending", {}) in events + + def test_no_outbox_dir_never_fires_pending(watcher_home): home, events = watcher_home server._broadcast_watched_changes(now=0.0) diff --git a/tests/tui_gateway/test_groups_methods.py b/tests/tui_gateway/test_groups_methods.py index ddd8ffa79ddf4..9c92643ba3a90 100644 --- a/tests/tui_gateway/test_groups_methods.py +++ b/tests/tui_gateway/test_groups_methods.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib from types import SimpleNamespace import pytest @@ -73,17 +74,108 @@ def test_capabilities_are_honest_about_the_driver_boundary(home): assert "groups.send" in srv._LONG_HANDLERS assert "groups.retry" in result["methods"] assert "groups.approve" in result["methods"] + assert "groups.desktop.claim" in result["methods"] + assert "groups.desktop.presence" in result["methods"] + assert "groups.desktop.renew" in result["methods"] + assert "groups.desktop.complete" in result["methods"] advertised = [ str(value).lower() for value in (*result["features"], *result["methods"]) ] assert not any( token in value - for token in ("attachment", "desktop", "messaging") + for token in ("attachment", "messaging") for value in advertised ) assert result["room_link"]["enabled"] is True +def test_desktop_mailbox_rpc_claim_and_complete(home): + from gateway.desktop_room_mailbox import default_db_path, enqueue_command + + enqueue_command( + default_db_path(), + command_id="messaging:one", + room_id="classic-room", + authority_hash=hashlib.sha256(b"authority:test").hexdigest(), + action="send", + payload={"message": "hello"}, + ) + + claimed = _result( + srv._methods["groups.desktop.claim"]( + 1, + { + "consumer_id": "desktop:test", + "room_authorities": [ + { + "room_id": "classic-room", + "authority_token": "authority:test", + } + ], + }, + ) + )["commands"] + assert [item["command_id"] for item in claimed] == ["messaging:one"] + + renewed = _result( + srv._methods["groups.desktop.renew"]( + 2, + { + "consumer_id": "desktop:test", + "command_id": claimed[0]["command_id"], + "lease_token": claimed[0]["lease_token"], + }, + ) + )["command"] + assert renewed["state"] == "claimed" + + completed = _result( + srv._methods["groups.desktop.complete"]( + 3, + { + "consumer_id": "desktop:test", + "command_id": claimed[0]["command_id"], + "lease_token": claimed[0]["lease_token"], + "success": True, + "result": {"thread_id": "thread-1"}, + }, + ) + )["command"] + assert completed["state"] == "completed" + + +def test_desktop_presence_rpc_renews_without_claiming_work(home): + from gateway.desktop_room_mailbox import ( + default_db_path, + register_projected_authorities, + room_available, + ) + + register_projected_authorities( + default_db_path(), + [{ + "room_id": "classic-room", + "authority_hash": hashlib.sha256(b"authority:test").hexdigest(), + }], + ) + + result = _result( + srv._methods["groups.desktop.presence"]( + 1, + { + "consumer_id": "desktop:test", + "room_authorities": [{ + "room_id": "classic-room", + "authority_token": "authority:test", + }], + }, + ) + ) + + assert result == {"room_ids": ["classic-room"]} + assert room_available(default_db_path(), "classic-room") is True + + def test_capabilities_and_invitation_advertise_scoped_roomlink(home, monkeypatch): monkeypatch.setenv("API_SERVER_KEY", "gateway-api-key-1234567890") monkeypatch.setenv("HERMES_PROFILE", "reviewer") @@ -132,6 +224,114 @@ def test_capabilities_and_invitation_advertise_scoped_roomlink(home, monkeypatch assert exact == {"revoked": True} +def test_reciprocal_control_network_calls_stay_off_the_rpc_reader_thread(): + assert { + "groups.control.invite", + "groups.control.register", + "groups.control.revoke", + } <= srv._LONG_HANDLERS + + +def test_reciprocal_control_link_is_scoped_to_the_live_peer_reservation( + home, monkeypatch +): + from gateway import hosted_room_controls, hosted_rooms + + control_calls = [] + + class _ControlClient: + def __init__(self, link): + self.link = link + + def summary(self): + control_calls.append(("summary", self.link.room_id)) + return { + "room": { + "room_id": self.link.room_id, + "authority_gateway_id": self.link.authority_gateway_id, + "authority_epoch": self.link.authority_epoch, + } + } + + def revoke(self): + control_calls.append(("revoke", self.link.room_id)) + return None + + monkeypatch.setattr( + "gateway.hosted_room_control_client.RoomControlHTTPClient", + _ControlClient, + ) + + monkeypatch.setenv("HERMES_ROOM_LINK_URL", "https://home.example.test/hermes") + service = srv.get_hosted_room_service() + service.create_room( + room_id="room-control", + name="Control room", + members=[ + { + "member_id": "member-peer", + "profile": "ops", + "handle": "ops", + "target": { + "kind": "peer", + "peer_id": "install-peer", + "installation_id": "install-peer", + "profile": "ops", + "capability_digest": "a" * 64, + }, + }, + {"member_id": "default", "profile": "default", "handle": "hermes"}, + ], + ) + invitation = _result( + srv._methods["groups.control.invite"]( + 1, + { + "room_id": "room-control", + "member_id": "member-peer", + "caller_install_id": "install-peer", + "request_id": "control-room-member-peer-v1", + }, + ) + ) + hosted_rooms.reserve_peer_room( + hosted_rooms.default_db_path(), + claims={ + "room_id": "room-control", + "member_id": "member-peer", + "target_profile": "ops", + "authority_gateway_id": invitation["authority_gateway_id"], + "authority_epoch": invitation["authority_epoch"], + }, + expires_at=invitation["expires_at"], + ) + registered = _result( + srv._methods["groups.control.register"]( + 2, + {**invitation, "profile": "ops"}, + ) + ) + assert registered["registered"] is True + links = hosted_room_controls.load_peer_control_links( + hosted_rooms.default_db_path() + ).links + assert links[0].room_id == "room-control" + assert links[0].home_url == "https://home.example.test/hermes" + assert control_calls == [("summary", "room-control")] + + revoked = _result( + srv._methods["groups.control.revoke"]( + 3, + {"room_id": "room-control", "member_id": "member-peer"}, + ) + ) + assert revoked["revoked"] == 1 + assert control_calls == [ + ("summary", "room-control"), + ("revoke", "room-control"), + ] + + def test_capabilities_disable_roomlink_when_run_replay_is_not_durable( home, monkeypatch ): @@ -754,8 +954,8 @@ def test_retry_and_approval_controls_forward_only_exact_local_coordinates( turn_id="turn-1", ) service = SimpleNamespace( - retry_room_task=lambda room_id, task_id: ( - calls.append(("retry", room_id, task_id)) + retry_room_task=lambda room_id, task_id, retry_id=None: ( + calls.append(("retry", room_id, task_id, retry_id)) or { "identity": identity, "status": "queued", @@ -772,9 +972,10 @@ def test_retry_and_approval_controls_forward_only_exact_local_coordinates( retried = _result( srv._methods["groups.retry"]( 1, - {"room_id": "room-1", "task_id": "task-1"}, + {"room_id": "room-1", "task_id": "task-1", "command_id": "retry-1"}, ) ) + assert calls[0] == ("retry", "room-1", "task-1", "retry-1") approved = _result( srv._methods["groups.approve"]( 2, @@ -800,7 +1001,7 @@ def test_retry_and_approval_controls_forward_only_exact_local_coordinates( } assert approved == {"approved": True, "result": {"resolved": 1}} assert calls == [ - ("retry", "room-1", "task-1"), + ("retry", "room-1", "task-1", "retry-1"), ( "approve", "room-1", @@ -938,9 +1139,17 @@ def revoke_room_routes(self, room_id): calls.append(("revoke", room_id)) monkeypatch.setattr(srv, "get_hosted_room_service", lambda: FakeService()) + monkeypatch.setattr( + "gateway.hosted_room_controls.revoke_home_control_tokens", + lambda _db_path, *, room_id: calls.append(("revoke-controls", room_id)), + ) _result(srv._methods["groups.disband"](9, {"room_id": "room-1"})) - assert calls == [("stop", "room-1"), ("revoke", "room-1")] + assert calls == [ + ("stop", "room-1"), + ("revoke", "room-1"), + ("revoke-controls", "room-1"), + ] assert _result(srv._methods["groups.list"](10, {}))["rooms"] == [] diff --git a/tests/tui_gateway/test_hosted_room_messaging_approvals.py b/tests/tui_gateway/test_hosted_room_messaging_approvals.py new file mode 100644 index 0000000000000..433f541139d34 --- /dev/null +++ b/tests/tui_gateway/test_hosted_room_messaging_approvals.py @@ -0,0 +1,811 @@ +"""Cross-process approval recovery for hosted Group Chat messaging.""" + +from __future__ import annotations + +import time + +import pytest + +from gateway import hosted_room_driver as driver +from gateway import hosted_room_messaging_approvals as approvals +from gateway import hosted_rooms +from tests.tui_gateway.test_hosted_room_service import _FakeRPC, _server +from tui_gateway.hosted_room_service import HostedRoomService + + +def _create_local_room(service: HostedRoomService) -> None: + service.local_profiles = lambda: ("default", "ops") + service.create_room( + room_id="room-1", + name="Approval room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + + +def test_cross_process_messaging_approval_is_consumed_by_room_worker(tmp_path): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + rpc = _FakeRPC() + service.rpc = rpc + service.runtime.rpc = rpc + service.local_profiles = lambda: ("default", "ops") + service.create_room( + room_id="room-1", + name="Release room", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + action = { + "kind": "approval", + "task_id": "task-approval-1", + "execution_generation": 2, + "session_id": "ops-session", + "request_id": "request-approval-1", + "approval": { + "description": "Run focused tests", + "command": "pytest -q tests/focused", + "choices": ["once", "deny"], + }, + } + service._set_pending_action("room-1", "ops", action) + pending = approvals.list_pending_approvals(db, room_id="room-1")[0] + approvals.submit_approval( + db, + service=None, + command_id="messaging-approval-1", + pending=pending, + choice="once", + ) + + recovered = HostedRoomService(_server(), db_path=db) + recovered_rpc = _FakeRPC() + recovered.rpc = recovered_rpc + recovered.runtime.rpc = recovered_rpc + recovered._apply_pending_control_approvals(recovered.bindings()[0]) + + assert recovered_rpc.approvals == [ + { + "session_id": "ops-session", + "request_id": "request-approval-1", + "choice": "once", + } + ] + assert approvals.list_pending_approval_commands(db, room_id="room-1") == [] + assert approvals.list_pending_approvals(db, room_id="room-1") == [] + + +def test_zero_resolution_keeps_messaging_approval_pending(tmp_path): + class ZeroResolutionRPC(_FakeRPC): + def approve(self, **_kwargs): + return {"resolved": 0} + + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + rpc = ZeroResolutionRPC() + service.rpc = rpc + service.runtime.rpc = rpc + _create_local_room(service) + service._set_pending_action( + "room-1", + "ops", + { + "kind": "approval", + "task_id": "task-1", + "execution_generation": 2, + "session_id": "member-session", + "request_id": "request-1", + "approval": { + "description": "Run focused tests", + "command": "pytest -q tests/focused", + "choices": ["once", "deny"], + }, + }, + ) + + with pytest.raises(RuntimeError, match="did not resolve"): + service.approve_room_task( + "room-1", + member_id="ops", + task_id="task-1", + execution_generation=2, + choice="once", + request_id="request-1", + ) + + assert service.status("room-1")["pending_actions"][0]["request_id"] == "request-1" + assert approvals.list_pending_approvals(db, room_id="room-1")[0][ + "request_id" + ] == "request-1" + + +def test_approval_is_fenced_to_the_authority_epoch(tmp_path): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + rpc = _FakeRPC() + service.rpc = rpc + service.runtime.rpc = rpc + service.local_profiles = lambda: ("default", "ops") + service.create_room( + room_id="room-1", + name="Authority fence", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + service._set_pending_action( + "room-1", + "ops", + { + "kind": "approval", + "task_id": "task-1", + "execution_generation": 1, + "session_id": "ops-session", + "request_id": "request-1", + "approval": {"choices": ["once", "deny"]}, + }, + ) + room = hosted_rooms.room_state(db, room_id="room-1") + hosted_rooms.claim_authority( + db, + room_id="room-1", + expected_gateway_id=str(room["authority_gateway_id"]), + expected_epoch=int(room["authority_epoch"]), + new_gateway_id="install:new-authority", + event_id="authority-transfer-1", + ) + + with pytest.raises(approvals.MessagingApprovalTerminalError, match="authority changed"): + service.approve_room_task( + "room-1", + member_id="ops", + task_id="task-1", + execution_generation=1, + choice="once", + request_id="request-1", + ) + + assert rpc.approvals == [] + + +def test_room_disappearance_terminally_completes_queued_decision(tmp_path): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + _create_local_room(service) + service._set_pending_action( + "room-1", + "ops", + { + "kind": "approval", + "task_id": "task-1", + "execution_generation": 1, + "session_id": "ops-session", + "request_id": "request-1", + "approval": {"choices": ["once", "deny"]}, + }, + ) + pending = approvals.list_pending_approvals(db, room_id="room-1")[0] + result = approvals.submit_approval( + db, + service=None, + command_id="approval-command-1", + pending=pending, + choice="once", + ) + assert result["queued"] is True + room = hosted_rooms.room_state(db, room_id="room-1") + hosted_rooms.disband_room( + db, + room_id="room-1", + expected_gateway_id=str(room["authority_gateway_id"]), + expected_epoch=int(room["authority_epoch"]), + ) + + service.bindings() + result = approvals.approval_command( + db, + command_id="approval-command-1", + ) + + assert result["state"] == "completed" + assert "no longer available" in result["result_text"] + assert approvals.list_pending_approval_commands(db, room_id="room-1") == [] + + +def test_late_pending_callback_keeps_its_original_authority_epoch(tmp_path): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + _create_local_room(service) + original = service.bindings()[0] + room = hosted_rooms.room_state(db, room_id="room-1") + hosted_rooms.claim_authority( + db, + room_id="room-1", + expected_gateway_id=str(room["authority_gateway_id"]), + expected_epoch=int(room["authority_epoch"]), + new_gateway_id="install:new-authority", + event_id="authority-transfer-1", + ) + task = { + "identity": driver.TaskIdentity( + "room-1", + "task-1", + "thread-1", + "turn-1", + ), + "execution_generation": 1, + "payload": {"target_member_id": "ops", "target_profile": "ops"}, + } + + service.runtime._report_pending_action( + original, + task, + session_id="ops-session", + info={ + "pending_approval": { + "request_id": "request-1", + "choices": ["once", "deny"], + } + }, + ) + + assert service.status("room-1")["pending_actions"] == [] + assert approvals.list_pending_approvals(db, room_id="room-1") == [] + + +def test_old_epoch_empty_callback_cannot_clear_newer_approval(tmp_path): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + _create_local_room(service) + original = service.bindings()[0] + task = { + "identity": driver.TaskIdentity( + "room-1", + "task-1", + "thread-1", + "turn-1", + ), + "execution_generation": 1, + "payload": {"target_member_id": "ops", "target_profile": "ops"}, + } + room = hosted_rooms.room_state(db, room_id="room-1") + hosted_rooms.claim_authority( + db, + room_id="room-1", + expected_gateway_id=str(room["authority_gateway_id"]), + expected_epoch=int(room["authority_epoch"]), + new_gateway_id=str(room["authority_gateway_id"]), + event_id="authority-transfer-1", + ) + current = service.bindings()[0] + current_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=current.gateway_id, + authority_epoch=current.authority_epoch, + process_generation=service.runtime.process_generation, + ttl_seconds=30, + clock=time.time, + ) + service.runtime._leases["room-1"] = current_lease + service.runtime._report_pending_action( + current, + task, + session_id="ops-session", + info={ + "pending_approval": { + "request_id": "request-new", + "choices": ["once", "deny"], + } + }, + ) + + service.runtime._report_pending_action( + original, + task, + session_id="ops-session", + info={}, + ) + + assert service.status("room-1")["pending_actions"][0]["request_id"] == ( + "request-new" + ) + assert approvals.list_pending_approvals(db, room_id="room-1")[0][ + "request_id" + ] == "request-new" + + +def test_old_process_empty_callback_cannot_clear_replacement_observation(tmp_path): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + _create_local_room(service) + binding = service.bindings()[0] + task = { + "identity": driver.TaskIdentity( + "room-1", + "task-1", + "thread-1", + "turn-1", + ), + "execution_generation": 1, + "payload": {"target_member_id": "ops", "target_profile": "ops"}, + } + old_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=binding.gateway_id, + authority_epoch=binding.authority_epoch, + process_generation="worker-old", + ttl_seconds=30, + clock=time.time, + ) + service.runtime._leases["room-1"] = old_lease + service.runtime.process_generation = "worker-old" + service.runtime._report_pending_action( + binding, + task, + session_id="ops-session", + info={ + "pending_approval": { + "request_id": "request-1", + "choices": ["once", "deny"], + } + }, + ) + driver.release_lease(db, old_lease, clock=time.time) + new_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=binding.gateway_id, + authority_epoch=binding.authority_epoch, + process_generation="worker-new", + ttl_seconds=30, + clock=time.time, + ) + service.runtime._leases["room-1"] = new_lease + service.runtime.process_generation = "worker-new" + service.runtime._report_pending_action( + binding, + task, + session_id="ops-session", + info={ + "pending_approval": { + "request_id": "request-1", + "choices": ["once", "deny"], + } + }, + ) + + service._set_pending_action( + "room-1", + "ops", + { + "kind": "approval", + "authority_gateway_id": binding.gateway_id, + "authority_epoch": binding.authority_epoch, + "task_id": "task-1", + "execution_generation": 1, + "session_id": "ops-session", + "observer_generation": "worker-old", + "observer_lease_generation": old_lease.lease_generation, + "request_id": "request-old-late", + "approval": {"choices": ["once", "deny"]}, + }, + ) + with pytest.raises(approvals.MessagingApprovalObservationStale): + approvals.persist_pending_approval( + db, + room_id="room-1", + member_id="ops", + action={ + "kind": "approval", + "authority_gateway_id": binding.gateway_id, + "authority_epoch": binding.authority_epoch, + "task_id": "task-1", + "execution_generation": 1, + "session_id": "ops-session", + "observer_generation": "worker-old", + "observer_lease_generation": old_lease.lease_generation, + "request_id": "request-old-atomic", + "approval": {"choices": ["once", "deny"]}, + }, + ) + service._set_pending_action( + "room-1", + "ops", + { + "kind": "approval_clear", + "authority_gateway_id": binding.gateway_id, + "authority_epoch": binding.authority_epoch, + "task_id": "task-1", + "execution_generation": 1, + "session_id": "ops-session", + "observer_generation": "worker-old", + }, + ) + + assert service._pending_actions[("room-1", "ops")]["observer_generation"] == ( + "worker-new" + ) + assert approvals.list_pending_approvals(db, room_id="room-1")[0][ + "observer_generation" + ] == "worker-new" + + +def test_new_worker_clear_retires_hydrated_old_observation(tmp_path): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + _create_local_room(service) + binding = service.bindings()[0] + task = { + "identity": driver.TaskIdentity( + "room-1", + "task-1", + "thread-1", + "turn-1", + ), + "execution_generation": 1, + "payload": {"target_member_id": "ops", "target_profile": "ops"}, + } + old_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=binding.gateway_id, + authority_epoch=binding.authority_epoch, + process_generation="worker-old", + ttl_seconds=30, + clock=time.time, + ) + service.runtime._leases["room-1"] = old_lease + service.runtime.process_generation = "worker-old" + service.runtime._report_pending_action( + binding, + task, + session_id="ops-session", + info={ + "pending_approval": { + "request_id": "request-1", + "choices": ["once", "deny"], + } + }, + ) + driver.release_lease(db, old_lease, clock=time.time) + new_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=binding.gateway_id, + authority_epoch=binding.authority_epoch, + process_generation="worker-new", + ttl_seconds=30, + clock=time.time, + ) + service.runtime._leases["room-1"] = new_lease + service.runtime.process_generation = "worker-new" + + service.runtime._report_pending_action( + binding, + task, + session_id="ops-session", + info={}, + ) + + assert service.status("room-1")["pending_actions"] == [] + assert approvals.list_pending_approvals(db, room_id="room-1") == [] + + +def test_transient_durable_clear_failure_is_retried_before_memory_cleanup( + tmp_path, + monkeypatch, +): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + rpc = _FakeRPC() + service.rpc = rpc + service.runtime.rpc = rpc + service.local_profiles = lambda: ("default", "ops") + service.create_room( + room_id="room-1", + name="Cleanup retry", + members=[ + {"member_id": "default", "profile": "default", "handle": "hermes"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + service._set_pending_action( + "room-1", + "ops", + { + "kind": "approval", + "task_id": "task-1", + "execution_generation": 1, + "session_id": "ops-session", + "request_id": "request-1", + "approval": {"choices": ["once", "deny"]}, + }, + ) + real_clear = approvals.clear_pending_approval + calls = [] + + def clear_once(*args, **kwargs): + calls.append(1) + if len(calls) == 1: + raise OSError("transient state.db failure") + return real_clear(*args, **kwargs) + + monkeypatch.setattr(approvals, "clear_pending_approval", clear_once) + + assert service.approve_room_task( + "room-1", + member_id="ops", + task_id="task-1", + execution_generation=1, + choice="once", + request_id="request-1", + ) == {"resolved": 1} + assert len(calls) == 2 + assert service.status("room-1")["pending_actions"] == [] + assert approvals.list_pending_approvals(db, room_id="room-1") == [] + + +def test_pending_callback_keeps_memory_until_durable_clear_succeeds( + tmp_path, + monkeypatch, +): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + _create_local_room(service) + service._set_pending_action( + "room-1", + "ops", + { + "kind": "approval", + "task_id": "task-1", + "execution_generation": 1, + "session_id": "member-session", + "request_id": "request-1", + "approval": {"choices": ["once", "deny"]}, + }, + ) + real_clear = approvals.clear_pending_approval + monkeypatch.setattr( + approvals, + "clear_pending_approval", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk busy")), + ) + + with pytest.raises(OSError, match="disk busy"): + service._set_pending_action("room-1", "ops", None) + + assert service.status("room-1")["pending_actions"][0]["request_id"] == "request-1" + monkeypatch.setattr(approvals, "clear_pending_approval", real_clear) + service._set_pending_action("room-1", "ops", None) + assert service.status("room-1")["pending_actions"] == [] + + +def test_failed_first_persist_rolls_back_memory_and_retries(tmp_path, monkeypatch): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + _create_local_room(service) + action = { + "kind": "approval", + "task_id": "task-1", + "execution_generation": 1, + "session_id": "ops-session", + "request_id": "request-1", + "approval": {"choices": ["once", "deny"]}, + } + real_persist = approvals.persist_pending_approval + calls = [] + + def persist_once(*args, **kwargs): + calls.append(1) + if len(calls) == 1: + raise OSError("state.db busy") + return real_persist(*args, **kwargs) + + monkeypatch.setattr(approvals, "persist_pending_approval", persist_once) + + with pytest.raises(OSError, match="busy"): + service._set_pending_action("room-1", "ops", action) + assert service.status("room-1")["pending_actions"] == [] + + service._set_pending_action("room-1", "ops", action) + assert len(calls) == 2 + assert service.status("room-1")["pending_actions"][0]["request_id"] == "request-1" + assert approvals.list_pending_approvals(db, room_id="room-1")[0][ + "request_id" + ] == "request-1" + + +def test_missing_room_retires_stale_approval_without_contacting_target(tmp_path): + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + rpc = _FakeRPC() + service.rpc = rpc + service.runtime.rpc = rpc + pending = approvals.persist_pending_approval( + db, + room_id="missing-room", + member_id="ops", + action={ + "kind": "approval", + "authority_gateway_id": hosted_rooms.local_authority_gateway_id(), + "authority_epoch": 1, + "task_id": "task-1", + "execution_generation": 1, + "session_id": "ops-session", + "request_id": "request-1", + "approval": {"choices": ["once", "deny"]}, + }, + ) + service._pending_actions[("missing-room", "ops")] = pending + + with pytest.raises(RuntimeError, match="no longer available"): + service.approve_room_task( + "missing-room", + member_id="ops", + task_id="task-1", + execution_generation=1, + choice="once", + request_id="request-1", + ) + + assert rpc.approvals == [] + assert approvals.list_pending_approvals(db, room_id="missing-room") == [] + + +def test_local_pending_approval_requires_exact_task_generation_and_request(tmp_path): + class ApprovalRPC(_FakeRPC): + def approve(self, *, session_id, request_id, choice): + self.approvals.append((session_id, request_id, choice)) + return {"resolved": 1} + + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + rpc = ApprovalRPC() + service.rpc = rpc + service.runtime.rpc = rpc + _create_local_room(service) + service.send( + room_id="room-1", + event_id="user-1", + payload={"text": "@ops inspect", "thread_id": "thread-1"}, + ) + task = driver.list_tasks(db, room_id="room-1", status="queued")[0] + binding = service.bindings()[0] + service.runtime.process_generation = "worker" + lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=binding.gateway_id, + authority_epoch=binding.authority_epoch, + process_generation="worker", + ttl_seconds=30, + clock=time.time, + ) + service.runtime._leases["room-1"] = lease + driver.start_task( + db, + task["identity"], + lease, + expected_cancel_generation=0, + clock=time.time, + ) + task = driver.get_task(db, task["identity"]) + service.runtime._report_pending_action( + binding, + task, + session_id="ops-session", + info={ + "pending_approval": { + "request_id": "approval-1", + "choices": ["once", "always", "deny"], + } + }, + ) + + action = service.status("room-1")["pending_actions"][0] + assert action["member_id"] == "ops" + assert action["approval"]["choices"] == ["once", "deny"] + with pytest.raises(RuntimeError, match="no longer pending"): + service.approve_room_task( + "room-1", + member_id="ops", + task_id=task["identity"].task_id, + execution_generation=1, + choice="once", + request_id="wrong-request", + ) + + assert service.approve_room_task( + "room-1", + member_id="ops", + task_id=task["identity"].task_id, + execution_generation=1, + choice="once", + request_id="approval-1", + ) == {"resolved": 1} + assert rpc.approvals == [("ops-session", "approval-1", "once")] + assert service.status("room-1")["pending_actions"] == [] + + +def test_local_room_approval_uses_the_exact_hidden_session(tmp_path): + service = HostedRoomService(_server(), db_path=tmp_path / "state.db") + rpc = _FakeRPC() + service.rpc = rpc + service.runtime.rpc = rpc + _create_local_room(service) + service._set_pending_action( + "room-1", + "ops", + { + "kind": "approval", + "task_id": "task-local-1", + "execution_generation": 1, + "session_id": "local-session", + "request_id": "approval-local-1", + "approval": { + "description": "Run focused tests", + "command": "pytest -q tests/focused", + "choices": ["once", "deny"], + }, + }, + ) + + assert service.approve_room_task( + "room-1", + member_id="ops", + task_id="task-local-1", + execution_generation=1, + choice="once", + request_id="approval-local-1", + ) == {"resolved": 1} + assert rpc.approvals == [ + { + "session_id": "local-session", + "request_id": "approval-local-1", + "choice": "once", + } + ] + assert service.status("room-1")["pending_actions"] == [] + + +def test_stale_local_approval_cannot_resolve_replacement_request(tmp_path): + service = HostedRoomService(_server(), db_path=tmp_path / "state.db") + rpc = _FakeRPC() + service.rpc = rpc + service.runtime.rpc = rpc + _create_local_room(service) + action = { + "kind": "approval", + "task_id": "task-local-1", + "execution_generation": 1, + "session_id": "local-session", + "approval": {"choices": ["once", "deny"]}, + } + service._set_pending_action( + "room-1", "ops", {**action, "request_id": "approval-A"} + ) + service._set_pending_action( + "room-1", "ops", {**action, "request_id": "approval-B"} + ) + + with pytest.raises(RuntimeError, match="no longer pending"): + service.approve_room_task( + "room-1", + member_id="ops", + task_id="task-local-1", + execution_generation=1, + choice="once", + request_id="approval-A", + ) + + assert rpc.approvals == [] + assert service.status("room-1")["pending_actions"][0]["request_id"] == ( + "approval-B" + ) diff --git a/tests/tui_gateway/test_hosted_room_messaging_retry.py b/tests/tui_gateway/test_hosted_room_messaging_retry.py new file mode 100644 index 0000000000000..0c98fe6b479e8 --- /dev/null +++ b/tests/tui_gateway/test_hosted_room_messaging_retry.py @@ -0,0 +1,325 @@ +"""Cross-process hosted Group Chat retry ownership tests.""" + +from __future__ import annotations + +import time +from pathlib import Path + +import pytest + +from gateway import hosted_room_controls, hosted_rooms +from gateway import hosted_room_driver as driver +from tests.tui_gateway.test_hosted_room_service import _FakeRPC, _server +from tui_gateway.hosted_room_service import HostedRoomService + + +@pytest.mark.parametrize( + "mode", + ["normal", "lease_takeover", "stopped", "already_cancelled"], +) +def test_active_worker_applies_retry_queued_by_another_process( + tmp_path: Path, + monkeypatch, + mode: str, +): + now = [100.0] + + def clock(): + return now[0] + + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + service.rpc = _FakeRPC() + service.runtime.rpc = service.rpc + service.runtime.clock = clock + service.runtime.lease_ttl_seconds = 30 + service.local_profiles = lambda: ("default", "ops") + service.create_room( + room_id="room-1", + name="Cross-process retry", + members=[ + {"member_id": "default", "profile": "default", "handle": "default"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + service.send( + room_id="room-1", + event_id="user-1", + payload={"text": "Retry this", "thread_id": "thread-1"}, + ) + task = driver.list_tasks(db, room_id="room-1", status="queued")[0] + old_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation="old-process", + ttl_seconds=1, + clock=clock, + ) + attempt = driver.start_task( + db, + task["identity"], + old_lease, + expected_cancel_generation=0, + clock=clock, + ) + now[0] = 102.0 + owner_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation=service.runtime.process_generation, + ttl_seconds=30, + clock=clock, + ) + driver.recover_room(db, owner_lease, clock=clock) + driver.defer_indeterminate_task( + db, + task["identity"], + owner_lease, + expected_execution_generation=attempt.execution_generation, + expected_cancel_generation=attempt.cancel_generation, + reason="member_unavailable", + clock=clock, + ) + hosted_room_controls.begin_control_retry( + db, + command_id="retry-command-1", + room_id="room-1", + member_id="messaging-owner", + task_ids=[task["identity"].task_id], + now=now[0], + ) + service.runtime._leases["room-1"] = owner_lease + + if mode in {"stopped", "already_cancelled"}: + room = hosted_rooms.room_state(db, room_id="room-1") + hosted_rooms.request_room_stop( + db, + room_id="room-1", + cancel_id="stop-before-retry", + expected_gateway_id=str(room["authority_gateway_id"]), + expected_epoch=int(room["authority_epoch"]), + ) + if mode == "already_cancelled": + service.runtime.cancel( + task["identity"], + cancel_id="stop-before-retry", + ) + + if mode == "lease_takeover": + real_complete_control_retry = hosted_room_controls.complete_control_retry + expire_once = [True] + + def complete_after_takeover(*args, **kwargs): + if expire_once[0]: + expire_once[0] = False + now[0] = owner_lease.expires_at + driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation="takeover-worker", + ttl_seconds=30, + clock=clock, + ) + return real_complete_control_retry(*args, **kwargs) + + monkeypatch.setattr( + hosted_room_controls, + "complete_control_retry", + complete_after_takeover, + ) + + if mode == "lease_takeover": + with pytest.raises(driver.StaleLeaseError): + service.runtime._process_room(service.bindings()[0]) + assert len( + hosted_room_controls.load_pending_control_retries( + db, + room_id="room-1", + ) + ) == 1 + service.runtime.process_generation = "takeover-worker" + service.runtime._leases.clear() + service.runtime._process_room(service.bindings()[0]) + else: + service.runtime._process_room(service.bindings()[0]) + + completed = hosted_room_controls.begin_control_retry( + db, + command_id="retry-command-1", + room_id="room-1", + member_id="messaging-owner", + task_ids=[task["identity"].task_id], + now=now[0] + 1, + ) + assert completed.result == {"action": "retry", "processed": 1} + assert driver.get_task(db, task["identity"])["status"] == ( + "cancelled" + if mode in {"stopped", "already_cancelled"} + else "settled" + ) + if mode not in {"stopped", "already_cancelled"}: + assert driver.retry_receipt_exists( + db, + room_id="room-1", + task_id=task["identity"].task_id, + retry_id=hosted_room_controls.control_retry_attempt_id( + "retry-command-1", + task["identity"].task_id, + ), + ) + if mode in {"stopped", "already_cancelled"}: + assert not any( + event["kind"] == "message.member" + for event in service._events("room-1") + ) + + +def test_worker_retry_redelivery_does_not_requeue_a_later_generation( + tmp_path: Path, + monkeypatch, +): + now = [100.0] + + def clock(): + return now[0] + + db = tmp_path / "state.db" + service = HostedRoomService(_server(), db_path=db) + service.rpc = _FakeRPC() + service.runtime.rpc = service.rpc + service.runtime.clock = clock + service.runtime.lease_ttl_seconds = 30 + service.local_profiles = lambda: ("default", "ops") + service.create_room( + room_id="room-1", + name="Retry redelivery", + members=[ + {"member_id": "default", "profile": "default", "handle": "default"}, + {"member_id": "ops", "profile": "ops", "handle": "ops"}, + ], + ) + service.send( + room_id="room-1", + event_id="user-1", + payload={"text": "Retry this", "thread_id": "thread-1"}, + ) + task = driver.list_tasks(db, room_id="room-1", status="queued")[0] + expired_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation="expired-process", + ttl_seconds=1, + clock=clock, + ) + first_attempt = driver.start_task( + db, + task["identity"], + expired_lease, + expected_cancel_generation=0, + clock=clock, + ) + now[0] = 102.0 + owner_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation="owner-process", + ttl_seconds=30, + clock=clock, + ) + driver.recover_room(db, owner_lease, clock=clock) + driver.defer_indeterminate_task( + db, + task["identity"], + owner_lease, + expected_execution_generation=first_attempt.execution_generation, + expected_cancel_generation=first_attempt.cancel_generation, + reason="member_unavailable", + clock=clock, + ) + hosted_room_controls.begin_control_retry( + db, + command_id="retry-command-1", + room_id="room-1", + member_id="messaging-owner", + task_ids=[task["identity"].task_id], + now=now[0], + ) + + real_complete = hosted_room_controls.complete_control_retry + lose_first_completion = [True] + + def complete_after_lost_response(*args, **kwargs): + if lose_first_completion[0]: + lose_first_completion[0] = False + raise driver.StaleLeaseError("simulated lost completion") + return real_complete(*args, **kwargs) + + monkeypatch.setattr( + hosted_room_controls, + "complete_control_retry", + complete_after_lost_response, + ) + service.runtime._leases["room-1"] = owner_lease + service._apply_pending_control_retries(service.bindings()[0], owner_lease) + + retry_id = hosted_room_controls.control_retry_attempt_id( + "retry-command-1", + task["identity"].task_id, + ) + assert driver.retry_receipt_exists( + db, + room_id="room-1", + task_id=task["identity"].task_id, + retry_id=retry_id, + ) + assert driver.get_task(db, task["identity"])["status"] == "queued" + assert len( + hosted_room_controls.load_pending_control_retries(db, room_id="room-1") + ) == 1 + + second_attempt = driver.start_task( + db, + task["identity"], + owner_lease, + expected_cancel_generation=0, + clock=clock, + ) + now[0] = owner_lease.expires_at + next_lease = driver.acquire_lease( + db, + room_id="room-1", + gateway_id=service.bindings()[0].gateway_id, + authority_epoch=1, + process_generation="next-process", + ttl_seconds=30, + clock=clock, + ) + driver.recover_room(db, next_lease, clock=clock) + driver.defer_indeterminate_task( + db, + task["identity"], + next_lease, + expected_execution_generation=second_attempt.execution_generation, + expected_cancel_generation=second_attempt.cancel_generation, + reason="member_unavailable_again", + clock=clock, + ) + + service.runtime._leases["room-1"] = next_lease + service._apply_pending_control_retries(service.bindings()[0], next_lease) + + assert driver.get_task(db, task["identity"])["status"] == "deferred" + assert hosted_room_controls.load_pending_control_retries( + db, + room_id="room-1", + ) == () diff --git a/tests/tui_gateway/test_hosted_room_service.py b/tests/tui_gateway/test_hosted_room_service.py index 0d1e3b731dfef..3b399e6b21091 100644 --- a/tests/tui_gateway/test_hosted_room_service.py +++ b/tests/tui_gateway/test_hosted_room_service.py @@ -788,8 +788,51 @@ def clock(): requeued = service.retry_room_task( "room-1", task_id=first["identity"].task_id, + retry_id="retry-1", ) assert requeued["status"] == "queued" + replayed = service.retry_room_task( + "room-1", + task_id=first["identity"].task_id, + retry_id="retry-1", + ) + assert replayed["status"] == "queued" + assert replayed["idempotent"] is True + with sqlite3.connect(db) as conn: + conn.execute( + """UPDATE hosted_room_driver_tasks + SET status='deferred', execution_generation=2 + WHERE room_id='room-1' AND task_id=?""", + (first["identity"].task_id,), + ) + conn.commit() + second_retry = service.retry_room_task( + "room-1", + task_id=first["identity"].task_id, + retry_id="retry-2", + ) + assert second_retry["status"] == "queued" + with sqlite3.connect(db) as conn: + conn.execute( + """UPDATE hosted_room_driver_tasks + SET status='deferred', execution_generation=3 + WHERE room_id='room-1' AND task_id=?""", + (first["identity"].task_id,), + ) + conn.commit() + delayed_first = service.retry_room_task( + "room-1", + task_id=first["identity"].task_id, + retry_id="retry-1", + ) + assert delayed_first["status"] == "deferred" + assert delayed_first["idempotent"] is True + third_retry = service.retry_room_task( + "room-1", + task_id=first["identity"].task_id, + retry_id="retry-3", + ) + assert third_retry["status"] == "queued" lease = service.runtime._leases["room-1"] retried = driver.start_task( db, @@ -798,7 +841,7 @@ def clock(): expected_cancel_generation=0, clock=clock, ) - assert retried.execution_generation == old_attempt.execution_generation + 1 + assert retried.execution_generation == third_retry["execution_generation"] + 1 def test_stop_fence_prevents_the_next_room_member_from_starting( @@ -899,92 +942,6 @@ def interrupt(self, *, profile, session_id, source, expected_task_id): assert stopping["cancel_id"] == "stop-1" -def test_local_pending_approval_requires_exact_task_generation_and_request( - tmp_path: Path, -): - class ApprovalRPC(_FakeRPC): - def __init__(self) -> None: - super().__init__() - self.approvals = [] - - def approve(self, *, session_id, request_id, choice): - self.approvals.append((session_id, request_id, choice)) - return {"resolved": 1} - - db = tmp_path / "state.db" - service = HostedRoomService(_server(), db_path=db) - rpc = ApprovalRPC() - service.rpc = rpc - service.runtime.rpc = rpc - service.local_profiles = lambda: ("default", "ops") - service.create_room( - room_id="room-1", - name="Release room", - members=[ - {"member_id": "default", "profile": "default", "handle": "hermes"}, - {"member_id": "ops", "profile": "ops", "handle": "ops"}, - ], - ) - service.send( - room_id="room-1", - event_id="user-1", - payload={"text": "@ops inspect", "thread_id": "thread-1"}, - ) - task = driver.list_tasks(db, room_id="room-1", status="queued")[0] - binding = service.bindings()[0] - lease = driver.acquire_lease( - db, - room_id="room-1", - gateway_id=binding.gateway_id, - authority_epoch=binding.authority_epoch, - process_generation="worker", - ttl_seconds=30, - clock=time.time, - ) - driver.start_task( - db, - task["identity"], - lease, - expected_cancel_generation=0, - clock=time.time, - ) - task = driver.get_task(db, task["identity"]) - service.runtime._report_pending_action( - task, - session_id="ops-session", - info={ - "pending_approval": { - "request_id": "approval-1", - "choices": ["once", "always", "deny"], - } - }, - ) - - action = service.status("room-1")["pending_actions"][0] - assert action["member_id"] == "ops" - assert action["approval"]["choices"] == ["once", "deny"] - with pytest.raises(RuntimeError, match="no longer pending"): - service.approve_room_task( - "room-1", - member_id="ops", - task_id=task["identity"].task_id, - execution_generation=1, - choice="once", - request_id="wrong-request", - ) - - assert service.approve_room_task( - "room-1", - member_id="ops", - task_id=task["identity"].task_id, - execution_generation=1, - choice="once", - request_id="approval-1", - ) == {"resolved": 1} - assert rpc.approvals == [("ops-session", "approval-1", "once")] - assert service.status("room-1")["pending_actions"] == [] - - def test_headless_room_publishes_peer_member_reply_without_desktop_transport( tmp_path: Path, ): @@ -1932,81 +1889,6 @@ def test_peer_approval_is_scoped_visible_and_resolvable(tmp_path: Path): assert service.status("room-1")["pending_actions"] == [] -def test_local_room_approval_uses_the_exact_hidden_session(tmp_path: Path): - service = HostedRoomService(_server(), db_path=tmp_path / "state.db") - rpc = _FakeRPC() - service.rpc = rpc - service.runtime.rpc = rpc - service._set_pending_action( - "room-1", - "local", - { - "kind": "approval", - "task_id": "task-local-1", - "execution_generation": 1, - "session_id": "local-session", - "request_id": "approval-local-1", - "approval": { - "description": "Run focused tests", - "command": "pytest -q tests/focused", - "choices": ["once", "deny"], - }, - }, - ) - - assert service.approve_room_task( - "room-1", - member_id="local", - task_id="task-local-1", - execution_generation=1, - choice="once", - request_id="approval-local-1", - ) == {"resolved": 1} - assert rpc.approvals == [ - { - "session_id": "local-session", - "request_id": "approval-local-1", - "choice": "once", - } - ] - assert service.status("room-1")["pending_actions"] == [] - - -def test_stale_local_approval_cannot_resolve_replacement_request(tmp_path: Path): - service = HostedRoomService(_server(), db_path=tmp_path / "state.db") - rpc = _FakeRPC() - service.rpc = rpc - service.runtime.rpc = rpc - action = { - "kind": "approval", - "task_id": "task-local-1", - "execution_generation": 1, - "session_id": "local-session", - "approval": {"choices": ["once", "deny"]}, - } - service._set_pending_action( - "room-1", "local", {**action, "request_id": "approval-A"} - ) - service._set_pending_action( - "room-1", "local", {**action, "request_id": "approval-B"} - ) - - with pytest.raises(RuntimeError, match="no longer pending"): - service.approve_room_task( - "room-1", - member_id="local", - task_id="task-local-1", - execution_generation=1, - choice="once", - request_id="approval-A", - ) - - assert rpc.approvals == [] - assert service.status("room-1")["pending_actions"][0]["request_id"] == ( - "approval-B" - ) - - def test_peer_recovery_replays_the_same_execution_generation(tmp_path: Path): db = tmp_path / "state.db" catalog = GatewayRoomCatalog.from_mapping( diff --git a/tui_gateway/hosted_room_driver.py b/tui_gateway/hosted_room_driver.py index 3223df4d22f7d..5b35d93a92b61 100644 --- a/tui_gateway/hosted_room_driver.py +++ b/tui_gateway/hosted_room_driver.py @@ -133,6 +133,10 @@ def __init__( rpc: InternalSessionRPC | None = None, transport_resolver: MemberTransportResolver | None = None, prepare_room: Callable[[HostedRoomBinding], None] | None = None, + prepare_leased_room: Callable[ + [HostedRoomBinding, state.DriverLease], None + ] + | None = None, publish_terminal: Callable[[HostedRoomBinding, Mapping[str, Any]], None] | None = None, pending_action: Callable[[str, str, Mapping[str, Any] | None], None] @@ -176,6 +180,7 @@ def __init__( self.transport_resolver = transport_resolver self.turn_lock = turn_lock self.prepare_room = prepare_room + self.prepare_leased_room = prepare_leased_room self.publish_terminal = publish_terminal self.pending_action = pending_action self.clock = clock @@ -298,7 +303,7 @@ def cancel( raise state.InvalidTaskTransitionError( f"cannot cancel task in state '{before['status']}'" ) - if before["status"] in {"queued", "deferred"}: + if before["status"] == "queued": try: cancelled = state.cancel_task( self.db_path, @@ -407,7 +412,12 @@ def _peer_stop_acknowledged( ) return self._info_acknowledges_peer_cancel(info, task) - def retry_indeterminate(self, identity: state.TaskIdentity) -> dict[str, Any]: + def retry_indeterminate( + self, + identity: state.TaskIdentity, + *, + retry_id: str | None = None, + ) -> dict[str, Any]: """Explicitly retry one uncertain attempt under the current room lease.""" task = state.get_task(self.db_path, identity) if task["status"] not in {"indeterminate", "deferred"}: @@ -426,6 +436,7 @@ def retry_indeterminate(self, identity: state.TaskIdentity) -> dict[str, Any]: expected_execution_generation=task["execution_generation"], expected_cancel_generation=task["cancel_generation"], clock=self.clock, + retry_id=retry_id, ) with self._status_lock: self._blocked_rooms.discard(identity.room_id) @@ -446,6 +457,7 @@ def retry_indeterminate(self, identity: state.TaskIdentity) -> dict[str, Any]: status=inspection.terminal.status, result=inspection.terminal.result, clock=self.clock, + retry_id=retry_id, ) if self.publish_terminal is not None: self.publish_terminal(binding, resolved) @@ -459,6 +471,7 @@ def retry_indeterminate(self, identity: state.TaskIdentity) -> dict[str, Any]: expected_cancel_generation=task["cancel_generation"], cancel_id=f"remote-cancel:{task['execution_generation']}", clock=self.clock, + retry_id=retry_id, ) if self.publish_terminal is not None: self.publish_terminal(binding, resolved) @@ -476,6 +489,7 @@ def retry_indeterminate(self, identity: state.TaskIdentity) -> dict[str, Any]: expected_execution_generation=task["execution_generation"], expected_cancel_generation=task["cancel_generation"], clock=self.clock, + retry_id=retry_id, ) with self._status_lock: self._blocked_rooms.discard(identity.room_id) @@ -587,6 +601,7 @@ def _settle_stopping_completion( def _report_pending_action( self, + binding: HostedRoomBinding, task: Mapping[str, Any], *, session_id: str, @@ -599,7 +614,25 @@ def _report_pending_action( payload.get("target_member_id") or payload.get("target_profile") or "" ) approval = info.get("pending_approval") or info.get("approval") - action = None + lease = self._leases.get(task["identity"].room_id) + observer_lease_generation = ( + lease.lease_generation + if lease is not None + and lease.gateway_id == binding.gateway_id + and lease.authority_epoch == binding.authority_epoch + and lease.process_generation == self.process_generation + else 0 + ) + action = { + "kind": "approval_clear", + "authority_gateway_id": binding.gateway_id, + "authority_epoch": binding.authority_epoch, + "task_id": task["identity"].task_id, + "execution_generation": int(task["execution_generation"]), + "session_id": session_id, + "observer_generation": self.process_generation, + "observer_lease_generation": observer_lease_generation, + } if isinstance(approval, Mapping): safe_approval = dict(approval) choices = [ @@ -610,10 +643,14 @@ def _report_pending_action( safe_approval["choices"] = choices or ["once", "deny"] action = { "kind": "approval", + "authority_gateway_id": binding.gateway_id, + "authority_epoch": binding.authority_epoch, "task_id": task["identity"].task_id, "execution_generation": int(task["execution_generation"]), "run_id": info.get("run_id"), "session_id": session_id, + "observer_generation": self.process_generation, + "observer_lease_generation": observer_lease_generation, "request_id": safe_approval.get("request_id"), "approval": safe_approval, } @@ -765,6 +802,8 @@ def _process_room(self, binding: HostedRoomBinding) -> None: if recovery_key not in self._recovered_leases: state.recover_room(self.db_path, lease, clock=self.clock) self._recovered_leases.add(recovery_key) + if self.prepare_leased_room is not None: + self.prepare_leased_room(binding, lease) if self._retry_stopping_tasks(binding, lease): with self._status_lock: self._blocked_rooms.add(binding.room_id) @@ -790,6 +829,8 @@ def _process_room(self, binding: HostedRoomBinding) -> None: expected_cancel_generation=task["cancel_generation"], clock=self.clock, ) + if attempt is None: + continue self._execute_attempt(binding, task, attempt) current = state.get_task(self.db_path, task["identity"]) if current["status"] not in state.TERMINAL_STATUSES: @@ -1083,7 +1124,12 @@ def _wait_for_terminal( session_id=session_id, source=ROOM_SESSION_SOURCE, ) - self._report_pending_action(task, session_id=session_id, info=info) + self._report_pending_action( + binding, + task, + session_id=session_id, + info=info, + ) remaining = max(0.0, deadline_monotonic - time.monotonic()) self._wake.wait(min(self.active_poll_interval_seconds, remaining)) self._wake.clear() @@ -1187,7 +1233,7 @@ def _inspect_abandoned_attempts(self, binding: HostedRoomBinding) -> None: continue transport = self._transport_for(binding, task) inspection = ( - self._inspect_local_recovery_session(task) + self._inspect_local_recovery_session(binding, task) if transport is self.rpc else self._inspect_recovery_session(binding, task) ) @@ -1233,7 +1279,12 @@ def _inspect_recovery_session( session_id=session_id, source=ROOM_SESSION_SOURCE, ) - self._report_pending_action(task, session_id=session_id, info=info) + self._report_pending_action( + binding, + task, + session_id=session_id, + info=info, + ) return _RecoveryInspection( terminal=receipt, active=_info_is_active_for(info, task["identity"]), @@ -1242,6 +1293,7 @@ def _inspect_recovery_session( def _inspect_local_recovery_session( self, + binding: HostedRoomBinding, task: Mapping[str, Any], ) -> _RecoveryInspection: """Check only live process state before explicit local recovery. @@ -1268,7 +1320,12 @@ def _inspect_local_recovery_session( session_id=session_id, source=ROOM_SESSION_SOURCE, ) - self._report_pending_action(task, session_id=session_id, info=info) + self._report_pending_action( + binding, + task, + session_id=session_id, + info=info, + ) return _RecoveryInspection( terminal=None, active=_info_is_active_for(info, task["identity"]), @@ -1300,7 +1357,7 @@ def _reconcile_indeterminate( self._transport_for(binding, task) is self.rpc and attempt_key not in self._inspected_indeterminate_attempts ): - inspection = self._inspect_local_recovery_session(task) + inspection = self._inspect_local_recovery_session(binding, task) self._inspected_indeterminate_attempts.add(attempt_key) if inspection.terminal is not None: resolved = state.resolve_indeterminate_task( diff --git a/tui_gateway/hosted_room_service.py b/tui_gateway/hosted_room_service.py index 9ba8ebfdaca3c..f9aaa604aa683 100644 --- a/tui_gateway/hosted_room_service.py +++ b/tui_gateway/hosted_room_service.py @@ -4,6 +4,7 @@ import contextlib import hashlib +import logging import os import threading import time @@ -15,6 +16,7 @@ from typing import Any from gateway import hosted_room_discussion as discussion +from gateway import hosted_room_controls from gateway import hosted_room_driver as driver from gateway import hosted_room_links from gateway import hosted_rooms @@ -27,7 +29,11 @@ HostedMemberDispatch, PROTOCOL_VERSION, ) -from tui_gateway.hosted_room_driver import HostedRoomBinding, HostedRoomRuntime +from tui_gateway.hosted_room_driver import ( + ROOM_SESSION_SOURCE, + HostedRoomBinding, + HostedRoomRuntime, +) from tui_gateway.hosted_room_server_rpc import HostedRoomServerRPC from tui_gateway.hosted_room_peer_http import PeerRunsHTTPClient, PeerRunsHTTPError from tui_gateway.hosted_room_peer_transport import ( @@ -37,6 +43,9 @@ ) +logger = logging.getLogger(__name__) + + _HOSTED_ROOM_IDLE_FALLBACK_SECONDS = 5.0 _HOSTED_ROOM_ACTIVE_POLL_SECONDS = 0.25 _HOSTED_ROOM_TERMINAL_GRACE_SECONDS = 30.0 @@ -121,6 +130,19 @@ def __init__( self._link_load_error = ",".join(errors) except Exception as exc: self._link_load_error = str(exc) + try: + from gateway import hosted_room_messaging_approvals as approvals + + for action in approvals.list_all_pending_approvals(self.db_path): + self._pending_actions[ + (str(action["room_id"]), str(action["member_id"])) + ] = action + except Exception as exc: + self._link_load_error = ",".join( + value + for value in (self._link_load_error, f"approval-recovery:{exc}") + if value + ) supplied_routes = dict(peer_routes or {}) supplied_clients = dict(peer_clients or {}) self.peer_routes.update(supplied_routes) @@ -137,6 +159,7 @@ def __init__( transport_resolver=self._resolve_member_transport, turn_lock=self._turn_lock, prepare_room=self.prepare_room, + prepare_leased_room=self._apply_pending_controls, publish_terminal=self.publish_terminal, pending_action=self._set_pending_action, poll_interval_seconds=_HOSTED_ROOM_IDLE_FALLBACK_SECONDS, @@ -159,6 +182,15 @@ def local_profiles(self) -> tuple[str, ...]: def bindings(self) -> tuple[HostedRoomBinding, ...]: local_gateway_id = hosted_rooms.local_authority_gateway_id() + try: + from gateway import hosted_room_messaging_approvals as approvals + + approvals.terminalize_unowned_approval_commands( + self.db_path, + local_gateway_id=local_gateway_id, + ) + except Exception as exc: + logger.warning("Group Chat approval cleanup will retry: %s", exc) return tuple( HostedRoomBinding( room_id=str(room["room_id"]), @@ -421,12 +453,197 @@ def _set_pending_action( member_id: str, action: Mapping[str, Any] | None, ) -> None: + from gateway import hosted_room_messaging_approvals as approvals + key = (room_id, member_id) + stored_action = ( + {**action, "member_id": member_id} if action is not None else None + ) + if stored_action is not None and stored_action.get("kind") in { + "approval", + "approval_clear", + }: + profile = "" + try: + room = hosted_rooms.room_state(self.db_path, room_id=room_id) + except hosted_rooms.RoomNotFoundError: + approvals.clear_pending_approval( + self.db_path, + room_id=room_id, + member_id=member_id, + ) + with self._policy_lock: + self._pending_actions.pop(key, None) + return + reported_gateway_id = str( + stored_action.get("authority_gateway_id") + or room.get("authority_gateway_id") + or "" + ) + reported_epoch = int( + stored_action.get("authority_epoch") + or room.get("authority_epoch") + or 0 + ) + reported_observer = str( + stored_action.get("observer_generation") or "legacy" + ) + reported_lease_generation = int( + stored_action.get("observer_lease_generation") or 0 + ) + if reported_observer != "legacy": + lease = self.runtime._leases.get(room_id) + if ( + lease is None + or lease.gateway_id != reported_gateway_id + or lease.authority_epoch != reported_epoch + or lease.process_generation != reported_observer + or lease.lease_generation != reported_lease_generation + ): + return + try: + driver.require_active_lease( + self.db_path, + lease, + clock=self.runtime.clock, + ) + except driver.StaleLeaseError: + return + if ( + reported_gateway_id != str(room["authority_gateway_id"]) + or reported_epoch != int(room["authority_epoch"]) + ): + is_clear = stored_action.get("kind") == "approval_clear" + approvals.clear_pending_approval( + self.db_path, + room_id=room_id, + member_id=member_id, + request_id=( + None if is_clear else stored_action.get("request_id") + ), + authority_gateway_id=reported_gateway_id, + authority_epoch=reported_epoch, + ) + with self._policy_lock: + current = self._pending_actions.get(key) + same_authority = str( + (current or {}).get("authority_gateway_id") or "" + ) == reported_gateway_id and int( + (current or {}).get("authority_epoch") or 0 + ) == reported_epoch + same_request = str((current or {}).get("request_id") or "") == str( + stored_action.get("request_id") or "" + ) + if same_authority and (is_clear or same_request): + self._pending_actions.pop(key, None) + return + stored_action["authority_gateway_id"] = reported_gateway_id + stored_action["authority_epoch"] = reported_epoch + stored_action["observer_generation"] = reported_observer + stored_action["observer_lease_generation"] = reported_lease_generation + if stored_action.get("kind") == "approval_clear": + with self._policy_lock: + current = self._pending_actions.get(key) + if ( + not isinstance(current, Mapping) + or str(current.get("authority_gateway_id") or "") + != reported_gateway_id + or int(current.get("authority_epoch") or 0) != reported_epoch + or str(current.get("task_id") or "") + != str(stored_action.get("task_id") or "") + or int(current.get("execution_generation") or 0) + != int(stored_action.get("execution_generation") or 0) + or str(current.get("session_id") or "") + != str(stored_action.get("session_id") or "") + ): + return + try: + approvals.clear_pending_approval( + self.db_path, + room_id=room_id, + member_id=member_id, + request_id=current.get("request_id"), + authority_gateway_id=reported_gateway_id, + authority_epoch=reported_epoch, + observer_generation=reported_observer, + observer_lease_generation=reported_lease_generation, + ) + except approvals.MessagingApprovalObservationStale: + return + with self._policy_lock: + if self._pending_actions.get(key) == current: + self._pending_actions.pop(key, None) + return + for member in room.get("members") or []: + if not isinstance(member, Mapping): + continue + if str(member.get("member_id") or member.get("profile") or "") != member_id: + continue + target = member.get("target") + profile = str( + (target.get("profile") if isinstance(target, Mapping) else "") + or member.get("profile") + or "" + ) + break + stored_action["profile"] = profile + changed = False + previous_action: Mapping[str, Any] | None = None with self._policy_lock: - if action is None: - self._pending_actions.pop(key, None) - else: - self._pending_actions[key] = {**action, "member_id": member_id} + current_action = self._pending_actions.get(key) + if stored_action is None: + if current_action is not None: + changed = True + previous_action = dict(current_action) + elif current_action != stored_action: + previous_action = ( + dict(current_action) if current_action is not None else None + ) + self._pending_actions[key] = stored_action + changed = True + if stored_action is None and changed: + approvals.clear_pending_approval( + self.db_path, + room_id=room_id, + member_id=member_id, + request_id=(previous_action or {}).get("request_id"), + authority_gateway_id=(previous_action or {}).get( + "authority_gateway_id" + ), + authority_epoch=(previous_action or {}).get("authority_epoch"), + ) + with self._policy_lock: + if self._pending_actions.get(key) == previous_action: + self._pending_actions.pop(key, None) + elif changed and stored_action.get("kind") == "approval": + try: + approvals.persist_pending_approval( + self.db_path, + room_id=room_id, + member_id=member_id, + action=stored_action, + ) + except Exception as exc: + with self._policy_lock: + if self._pending_actions.get(key) == stored_action: + if previous_action is None: + self._pending_actions.pop(key, None) + else: + self._pending_actions[key] = dict(previous_action) + if isinstance(exc, approvals.MessagingApprovalObservationStale): + return + raise + if stored_action is not None and stored_action.get("kind") == "approval": + binding = next( + ( + candidate + for candidate in self.bindings() + if candidate.room_id == room_id + ), + None, + ) + if binding is not None: + self._apply_pending_control_approvals(binding) def _rotate_route_grant( self, @@ -699,6 +916,164 @@ def prepare_room(self, binding: HostedRoomBinding) -> None: elif decision.status in {"settled", "bounded"}: self._append_room_status(room, decision) + def _apply_pending_control_retries( + self, + binding: HostedRoomBinding, + lease: driver.DriverLease, + ) -> None: + """Apply cross-process Retry commands under this worker's active lease.""" + + pending = hosted_room_controls.load_pending_control_retries( + self.db_path, + room_id=binding.room_id, + ) + if not pending: + return + room = hosted_rooms.room_state(self.db_path, room_id=binding.room_id) + stopped_through_seq = self._policy_snapshot(room).stopped_through_seq + tasks = { + task["identity"].task_id: task + for task in driver.list_tasks(self.db_path, room_id=binding.room_id) + } + for command in pending: + try: + for task_id in command.task_ids: + task = tasks.get(task_id) + if task is None: + continue + status = str(task.get("status") or "") + if status in driver.TERMINAL_STATUSES or status == "stopping": + continue + source_event_seq = int( + (task.get("payload") or {}).get("source_event_seq") or 0 + ) + if source_event_seq < stopped_through_seq: + self.runtime.cancel( + task["identity"], + cancel_id=f"stop-fence:{stopped_through_seq}", + ) + continue + if status in {"deferred", "indeterminate"}: + self.retry_room_task( + binding.room_id, + task_id=task_id, + retry_id=hosted_room_controls.control_retry_attempt_id( + command.command_id, task_id + ), + ) + hosted_room_controls.complete_control_retry( + self.db_path, + command_id=command.command_id, + result={ + "action": "retry", + "processed": len(command.task_ids), + }, + lease=lease, + now=self.runtime.clock(), + ) + except Exception as exc: + hosted_room_controls.defer_control_retry( + self.db_path, + command_id=command.command_id, + now=self.runtime.clock(), + ) + logger.warning( + "Hosted room retry command %s remains pending: %s", + command.command_id, + exc, + ) + + def _apply_pending_controls( + self, + binding: HostedRoomBinding, + lease: driver.DriverLease, + ) -> None: + self._apply_pending_control_retries(binding, lease) + self._apply_pending_control_approvals(binding) + + def _apply_pending_control_approvals( + self, + binding: HostedRoomBinding, + ) -> None: + from gateway import hosted_room_messaging_approvals as approvals + + pending = approvals.list_pending_approval_commands( + self.db_path, + room_id=binding.room_id, + ) + if not pending: + return + durable = { + ( + item["authority_gateway_id"], + item["authority_epoch"], + item["member_id"], + item["task_id"], + item["execution_generation"], + item["request_id"], + ) + for item in approvals.list_pending_approvals( + self.db_path, + room_id=binding.room_id, + ) + } + for command in pending: + coordinates = ( + str(command["authority_gateway_id"]), + int(command["authority_epoch"]), + str(command["member_id"]), + str(command["task_id"]), + int(command["execution_generation"]), + str(command["request_id"]), + ) + if coordinates not in durable: + approvals.complete_approval_command( + self.db_path, + command_id=command["command_id"], + result="Approval was already resolved.", + ) + continue + with self._policy_lock: + action = self._pending_actions.get( + (binding.room_id, coordinates[2]) + ) + if ( + not isinstance(action, Mapping) + or coordinates[0] != binding.gateway_id + or coordinates[1] != binding.authority_epoch + or str(action.get("authority_gateway_id") or "") != coordinates[0] + or int(action.get("authority_epoch") or 0) != coordinates[1] + or str(action.get("task_id") or "") != coordinates[3] + or int(action.get("execution_generation") or 0) != coordinates[4] + or str(action.get("request_id") or "") != coordinates[5] + ): + continue + try: + self.approve_room_task( + binding.room_id, + member_id=coordinates[2], + task_id=coordinates[3], + execution_generation=coordinates[4], + choice=str(command["choice"]), + request_id=coordinates[5], + ) + result = ( + "Approved once." + if command["choice"] == "once" + else "Denied." + ) + approvals.complete_approval_command( + self.db_path, + command_id=command["command_id"], + result=result, + ) + except Exception as exc: + logger.warning( + "Hosted room approval command %s remains pending: %s", + command["command_id"], + exc, + ) + def publish_terminal( self, binding: HostedRoomBinding, @@ -742,6 +1117,23 @@ def send( event_id: str, payload: Any, ) -> dict[str, Any]: + return self.send_server_owned( + room_id=room_id, + event_id=event_id, + payload=payload, + actor={"kind": "user", "id": "desktop"}, + ) + + def send_server_owned( + self, + *, + room_id: str, + event_id: str, + payload: Any, + actor: Mapping[str, Any], + ) -> dict[str, Any]: + """Append a user event whose actor was derived by trusted gateway code.""" + normalized = discussion.validate_user_payload(payload) room = self._owned_room(room_id) event = hosted_rooms.append_event( @@ -749,7 +1141,7 @@ def send( room_id=room_id, event_id=event_id, kind="message.user", - actor={"kind": "user", "id": "desktop"}, + actor=dict(actor), payload=normalized, authority_gateway_id=str(room["authority_gateway_id"]), authority_epoch=int(room["authority_epoch"]), @@ -821,16 +1213,19 @@ def stop_room( self.runtime.wakeup() return cancelled - def retry_room_task(self, room_id: str, *, task_id: str) -> dict[str, Any]: + def retry_room_task( + self, + room_id: str, + *, + task_id: str, + retry_id: str | None = None, + ) -> dict[str, Any]: """Retry one uncertain or deferred task only after explicit user action.""" task = next( ( candidate - for status in ("indeterminate", "deferred") - for candidate in driver.list_tasks( - self.db_path, room_id=room_id, status=status - ) + for candidate in driver.list_tasks(self.db_path, room_id=room_id) if candidate["identity"].task_id == task_id ), None, @@ -839,7 +1234,18 @@ def retry_room_task(self, room_id: str, *, task_id: str) -> dict[str, Any]: raise driver.InvalidTaskTransitionError( "no retryable room task matches task_id" ) - return self.runtime.retry_indeterminate(task["identity"]) + if retry_id and driver.retry_receipt_exists( + self.db_path, + room_id=room_id, + task_id=task_id, + retry_id=retry_id, + ): + return {**task, "idempotent": True} + if task["status"] not in {"indeterminate", "deferred"}: + raise driver.InvalidTaskTransitionError( + "no retryable room task matches task_id" + ) + return self.runtime.retry_indeterminate(task["identity"], retry_id=retry_id) def approve_room_task( self, @@ -857,6 +1263,42 @@ def approve_room_task( client = self.peer_clients.get(key) with self._policy_lock: action = self._pending_actions.get(key) + from gateway import hosted_room_messaging_approvals as approvals + + try: + room = self._owned_room(room_id) + except hosted_rooms.RoomNotFoundError: + approvals.clear_pending_approval( + self.db_path, + room_id=room_id, + member_id=member_id, + request_id=request_id, + authority_gateway_id=(action or {}).get("authority_gateway_id"), + authority_epoch=(action or {}).get("authority_epoch"), + ) + with self._policy_lock: + current = self._pending_actions.get(key) + if current == action: + self._pending_actions.pop(key, None) + raise approvals.MessagingApprovalTerminalError( + "Approval expired because the Group Chat is no longer available." + ) from None + except hosted_rooms.AuthorityConflictError: + approvals.clear_pending_approval( + self.db_path, + room_id=room_id, + member_id=member_id, + request_id=request_id, + authority_gateway_id=(action or {}).get("authority_gateway_id"), + authority_epoch=(action or {}).get("authority_epoch"), + ) + with self._policy_lock: + current = self._pending_actions.get(key) + if current == action: + self._pending_actions.pop(key, None) + raise approvals.MessagingApprovalTerminalError( + "Approval expired because Group Chat authority changed." + ) from None requested_approval_id = str(request_id or "") pending_approval_id = str((action or {}).get("request_id") or "") if ( @@ -869,6 +1311,27 @@ def approve_room_task( or requested_approval_id != pending_approval_id ): raise RuntimeError("room approval is no longer pending") + if ( + str((action or {}).get("authority_gateway_id") or "") + != str(room["authority_gateway_id"]) + or int((action or {}).get("authority_epoch") or 0) + != int(room["authority_epoch"]) + ): + approvals.clear_pending_approval( + self.db_path, + room_id=room_id, + member_id=member_id, + request_id=request_id, + authority_gateway_id=(action or {}).get("authority_gateway_id"), + authority_epoch=(action or {}).get("authority_epoch"), + ) + with self._policy_lock: + current = self._pending_actions.get(key) + if current == action: + self._pending_actions.pop(key, None) + raise approvals.MessagingApprovalTerminalError( + "Approval expired because Group Chat authority changed." + ) if choice not in {"once", "deny"}: raise RuntimeError("room approval choice must be once or deny") approve = getattr(client, "approve_receipt", None) @@ -882,15 +1345,40 @@ def approve_room_task( ) else: session_id = str(action.get("session_id") or "") + profile = str(action.get("profile") or "") if not session_id: raise RuntimeError("local room approval identity is unavailable") + if profile: + resumed = self.rpc.resume( + profile=profile, + session_id=session_id, + source=ROOM_SESSION_SOURCE, + ) + session_id = str((resumed or {}).get("session_id") or session_id) result = self.rpc.approve( session_id=session_id, request_id=requested_approval_id, choice=choice, ) - if result is None: - raise RuntimeError("room approval target is unavailable") + if not isinstance(result, Mapping) or int(result.get("resolved") or 0) != 1: + raise RuntimeError("room approval target did not resolve the exact request") + for attempt in range(2): + try: + approvals.clear_pending_approval( + self.db_path, + room_id=room_id, + member_id=member_id, + request_id=requested_approval_id, + authority_gateway_id=(action or {}).get( + "authority_gateway_id" + ), + authority_epoch=(action or {}).get("authority_epoch"), + ) + break + except Exception: + if attempt: + raise + logger.warning("Retrying durable Group Chat approval cleanup") with self._policy_lock: current = self._pending_actions.get(key) if ( @@ -923,7 +1411,18 @@ def status(self, room_id: str | None = None) -> dict[str, Any]: ] with self._policy_lock: pending_actions.extend( - dict(action) + { + key: value + for key, value in action.items() + if key + not in { + "profile", + "authority_gateway_id", + "authority_epoch", + "observer_generation", + "observer_lease_generation", + } + } for ( action_room_id, _member_id, diff --git a/tui_gateway/methods_groups.py b/tui_gateway/methods_groups.py index 0d05bd8c3c4bf..aae6cc529aa2a 100644 --- a/tui_gateway/methods_groups.py +++ b/tui_gateway/methods_groups.py @@ -9,6 +9,7 @@ import os import threading +import time _registry = HandlerRegistry() method = _registry.method @@ -30,6 +31,9 @@ "groups.peer.revoke_exact", "groups.peer.revoke", "groups.peer.register", + "groups.control.invite", + "groups.control.register", + "groups.control.revoke", }) _service_lock = threading.Lock() @@ -44,6 +48,7 @@ def bind_server(server) -> None: global _bound_server _bound_server = server server._profile_execution_policy = _profile_execution_policy + server._revoke_peer_room_control = _revoke_peer_room_control def start_hosted_room_service(): @@ -142,6 +147,15 @@ def _api_server_key(profile: str | None = None) -> str: return (os.getenv("API_SERVER_KEY") or "").strip() +def _revoke_peer_room_control(room_id: str, member_id: str) -> int: + from gateway.hosted_room_control_client import revoke_stored_peer_control + from gateway.hosted_rooms import default_db_path + + return revoke_stored_peer_control( + default_db_path(), room_id=room_id, member_id=member_id + ) + + def _profile_execution_policy(profile: str) -> dict: """Resolve execution policy under the exact multiplexed profile home.""" @@ -250,6 +264,8 @@ def _(rid, params: dict) -> dict: "features": [ "authority_epoch", "coordinator_fencing", + "desktop_compatibility_mailbox", + "reciprocal_room_control", "room_identity", "monotonic_log", "idempotent_send", @@ -275,12 +291,93 @@ def _(rid, params: dict) -> dict: "groups.peer.revoke_exact", "groups.peer.revoke", "groups.peer.register", + "groups.desktop.claim", + "groups.desktop.presence", + "groups.desktop.renew", + "groups.desktop.complete", + "groups.control.invite", + "groups.control.register", + "groups.control.revoke", ], "max_log_limit": MAX_LOG_LIMIT, }, ) +@method("groups.desktop.claim") +def _(rid, params: dict) -> dict: + """Advertise classic rooms and lease pending messaging commands.""" + + try: + from gateway.desktop_room_mailbox import claim_commands, default_db_path + + commands = claim_commands( + default_db_path(), + consumer_id=params.get("consumer_id"), + room_authorities=params.get("room_authorities", []), + actions=params.get("actions"), + limit=params.get("limit", 8), + ) + return _ok(rid, {"commands": commands}) + except Exception as exc: + return _err(rid, 4130, str(exc)) + + +@method("groups.desktop.presence") +def _(rid, params: dict) -> dict: + """Renew classic-room ownership without claiming pending commands.""" + + try: + from gateway.desktop_room_mailbox import default_db_path, refresh_presence + + room_ids = refresh_presence( + default_db_path(), + consumer_id=params.get("consumer_id"), + room_authorities=params.get("room_authorities", []), + ) + return _ok(rid, {"room_ids": room_ids}) + except Exception as exc: + return _err(rid, 4137, str(exc)) + + +@method("groups.desktop.complete") +def _(rid, params: dict) -> dict: + """Commit the outcome of one classic-room compatibility command.""" + + try: + from gateway.desktop_room_mailbox import complete_command, default_db_path + + command = complete_command( + default_db_path(), + consumer_id=params.get("consumer_id"), + command_id=params.get("command_id"), + lease_token=params.get("lease_token"), + success=params.get("success") is True, + result=params.get("result", {}), + ) + return _ok(rid, {"command": command}) + except Exception as exc: + return _err(rid, 4131, str(exc)) + + +@method("groups.desktop.renew") +def _(rid, params: dict) -> dict: + """Renew one live classic-room command lease while its turn settles.""" + + try: + from gateway.desktop_room_mailbox import default_db_path, renew_command + + command = renew_command( + default_db_path(), + consumer_id=params.get("consumer_id"), + command_id=params.get("command_id"), + lease_token=params.get("lease_token"), + ) + return _ok(rid, {"command": command}) + except Exception as exc: + return _err(rid, 4132, str(exc)) + + @method("groups.peer.invite") def _(rid, params: dict) -> dict: """Mint one target-issued room/profile grant for a prospective home.""" @@ -373,6 +470,9 @@ def _(rid, params: dict) -> dict: claims.get("status_expires_at", claims["expires_at"]) ), ) + _revoke_peer_room_control( + str(claims["room_id"]), str(claims["member_id"]) + ) return _ok(rid, {"revoked": True}) except Exception as exc: return _err(rid, 4122, str(exc)) @@ -516,6 +616,166 @@ def _(rid, params: dict) -> dict: return _err(rid, 5120, str(exc)) +@method("groups.control.invite") +def _(rid, params: dict) -> dict: + """Issue one durable return-control credential to a room participant.""" + + try: + from gateway import hosted_room_controls + from gateway.hosted_room_peer import ( + PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION, + local_catalog_mapping, + ) + from gateway.hosted_rooms import local_authority_gateway_id, room_state + + service = get_hosted_room_service() + if service is None: + return _err(rid, 4123, _WORKER_UNAVAILABLE) + room = room_state(service.db_path, room_id=params.get("room_id")) + member_id = str(params.get("member_id") or "") + caller_install_id = str(params.get("caller_install_id") or "") + member = next( + ( + item + for item in room["members"] + if str(item.get("member_id") or "") == member_id + ), + None, + ) + target = member.get("target") if isinstance(member, dict) else None + if ( + not isinstance(target, dict) + or target.get("kind") != "peer" + or str(target.get("installation_id") or "") != caller_install_id + ): + raise ValueError("control participant does not match the frozen room member") + profile = _requested_profile(params) + catalog = local_catalog_mapping( + installation_id=local_authority_gateway_id(), + protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,), + link_modes=("direct",), + text=True, + attachments=False, + target_profile=profile, + execution_policy=_profile_execution_policy(profile), + ) + endpoint = catalog.get("endpoint") + home_url = ( + str(endpoint.get("url") or "") + if isinstance(endpoint, dict) and endpoint.get("available") is True + else "" + ) + if not home_url: + raise ValueError("room authority has no reachable control endpoint") + request_id = str(params.get("request_id") or "").strip() + if not request_id: + raise ValueError("room control invitation requires request_id") + now = time.time() + issued = hosted_room_controls.issue_home_control_token( + service.db_path, + room_id=room["room_id"], + member_id=member_id, + authority_gateway_id=room["authority_gateway_id"], + authority_epoch=int(room["authority_epoch"]), + expires_at=hosted_room_controls.ROOM_LIFETIME_EXPIRES_AT, + request_id=request_id, + now=now, + ) + return _ok( + rid, + { + "room_id": issued.room_id, + "member_id": issued.member_id, + "authority_gateway_id": issued.authority_gateway_id, + "authority_epoch": issued.authority_epoch, + "room_name": str(room.get("name") or room["room_id"]), + "member_count": len(room["members"]), + "control_token": issued.control_token, + "home_url": home_url, + "expires_at": issued.expires_at, + }, + ) + except Exception as exc: + return _err(rid, 4150, str(exc)) + + +@method("groups.control.register") +def _(rid, params: dict) -> dict: + """Persist one private return route on the participating gateway.""" + + try: + from gateway import hosted_room_controls + from gateway.hosted_room_control_client import RoomControlHTTPClient + from gateway.hosted_rooms import default_db_path + + profile = _requested_profile(params) + if not hosted_room_controls.peer_reservation_matches( + default_db_path(), + room_id=params.get("room_id"), + member_id=params.get("member_id"), + target_profile=profile, + authority_gateway_id=params.get("authority_gateway_id"), + authority_epoch=int(params.get("authority_epoch") or 0), + ): + raise ValueError("room control route has no matching live reservation") + saved = hosted_room_controls.save_peer_control_link( + default_db_path(), + room_id=params.get("room_id"), + member_id=params.get("member_id"), + home_url=params.get("home_url"), + authority_gateway_id=params.get("authority_gateway_id"), + authority_epoch=int(params.get("authority_epoch") or 0), + room_name=params.get("room_name"), + member_count=params.get("member_count"), + control_token=params.get("control_token"), + expires_at=params.get("expires_at"), + allow_rotation=True, + ) + try: + summary = RoomControlHTTPClient(saved.link).summary() + summary_room = summary.get("room") if isinstance(summary, dict) else None + if ( + not isinstance(summary_room, dict) + or str(summary_room.get("room_id") or "") != saved.link.room_id + or str(summary_room.get("authority_gateway_id") or "") + != saved.link.authority_gateway_id + or int(summary_room.get("authority_epoch") or 0) + != saved.link.authority_epoch + ): + raise ValueError("room control authority returned mismatched scope") + except Exception: + hosted_room_controls.delete_peer_control_links( + default_db_path(), + room_id=saved.link.room_id, + member_id=saved.link.member_id, + ) + raise + return _ok( + rid, + { + "registered": True, + "idempotent": saved.idempotent, + "room_id": saved.link.room_id, + "member_id": saved.link.member_id, + }, + ) + except Exception as exc: + return _err(rid, 4151, str(exc)) + + +@method("groups.control.revoke") +def _(rid, params: dict) -> dict: + """Revoke a participant's private return route idempotently.""" + + try: + room_id = str(params.get("room_id") or "") + member_id = str(params.get("member_id") or "") + removed = _revoke_peer_room_control(room_id, member_id) + return _ok(rid, {"revoked": removed}) + except Exception as exc: + return _err(rid, 4152, str(exc)) + + @method("groups.list") def _(rid, params: dict) -> dict: """List rooms hosted by this gateway.""" @@ -677,7 +937,9 @@ def _(rid, params: dict) -> dict: if service is None: return _err(rid, 4123, _WORKER_UNAVAILABLE) - def disband_with_state(state: dict | None = None) -> dict: + def disband_with_controls(state: dict | None = None) -> dict: + from gateway import hosted_room_controls + local_gateway_id = local_authority_gateway_id() if state is not None and ( str(state["authority_gateway_id"]) != local_gateway_id @@ -685,7 +947,7 @@ def disband_with_state(state: dict | None = None) -> dict: raise AuthorityConflictError( "This Group Chat is managed by another gateway." ) - return disband_room( + tombstone = disband_room( service.db_path, room_id=params.get("room_id"), expected_gateway_id=str( @@ -695,6 +957,11 @@ def disband_with_state(state: dict | None = None) -> dict: state["authority_epoch"] if state is not None else 1 ), ) + hosted_room_controls.revoke_home_control_tokens( + service.db_path, + room_id=params.get("room_id"), + ) + return tombstone try: existing = room_state( @@ -703,10 +970,10 @@ def disband_with_state(state: dict | None = None) -> dict: include_disbanded=True, ) except RoomHistoryExpiredError: - tombstone = disband_with_state() + tombstone = disband_with_controls() return _ok(rid, {"tombstone": tombstone}) if existing.get("disbanded_at") is not None: - tombstone = disband_with_state(existing) + tombstone = disband_with_controls(existing) return _ok(rid, {"tombstone": tombstone}) service.stop_room( str(params.get("room_id") or ""), @@ -714,7 +981,7 @@ def disband_with_state(state: dict | None = None) -> dict: require_acknowledged=True, ) service.revoke_room_routes(str(params.get("room_id") or "")) - tombstone = disband_with_state(existing) + tombstone = disband_with_controls(existing) return _ok(rid, {"tombstone": tombstone}) except HostedRoomError as exc: reason = getattr(exc, "reason", None) @@ -771,6 +1038,7 @@ def _(rid, params: dict) -> dict: task = service.retry_room_task( str(params.get("room_id") or ""), task_id=str(params.get("task_id") or ""), + retry_id=str(params.get("command_id") or "") or None, ) identity = task.get("identity") if isinstance(task, dict) else None receipt = { diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c25266436e9de..3831c4b48c0bd 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5213,6 +5213,17 @@ def _bot_relay_outbox_sig(): return _bot_relay_outbox_seen or None +def _desktop_room_mailbox_sig(): + """mtime of commands written by a messaging process for Desktop rooms.""" + + home = _watcher_home() + root = home.parent.parent if home.parent.name == "profiles" else home + try: + return (root / "desktop_room_mailbox.pending").stat().st_mtime_ns + except OSError: + return None + + # Watched change signals: event → (check interval, signature fn, payload fn). # Signatures are stat/dict-lookup cheap, same bar as the skin watcher; the # check interval keeps the pricier probes (pet resolves the active sheet off @@ -5226,6 +5237,11 @@ def _bot_relay_outbox_sig(): # Cross-connection DM latency: 1s check so a queued envelope reaches the # Desktop's push-triggered drain fast; the Desktop's poll stays backstop. "bot_relay.outbox.pending": (1.0, _bot_relay_outbox_sig, lambda: {}), + "desktop_rooms.commands.pending": ( + 1.0, + _desktop_room_mailbox_sig, + lambda: {}, + ), } # state.db moves on every message append during a streaming turn, and the From 208ccdba09a32ce4eaa02227aeca47c8aa44b1ed Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:23:29 +0200 Subject: [PATCH 07/16] refactor(bot-mode): split messaging ownership boundaries --- .../desktop-room-command-client.test.ts | 6 +- .../desktop-room-command-client.ts | 2 +- .../src/plugins/hermes-bots/group-chat.ts | 10 +- .../src/plugins/hermes-bots/group-rounds.ts | 19 +- .../hermes-bots/hosted-room-runtime.test.ts | 131 ++++---- gateway/desktop_room_mailbox.py | 46 ++- gateway/group_chat_slash.py | 70 ++-- gateway/hosted_room_controls.py | 124 ++++--- gateway/hosted_room_driver.py | 311 ++---------------- gateway/hosted_room_driver_schema.py | 288 ++++++++++++++++ gateway/hosted_room_messaging.py | 289 ++++++++-------- gateway/hosted_room_messaging_approvals.py | 64 ++-- gateway/platforms/api_server_room_controls.py | 40 ++- gateway/run.py | 33 -- gateway/session.py | 175 +--------- gateway/session_source.py | 184 +++++++++++ gateway/slash_commands.py | 47 +-- gateway/slash_dispatch.py | 78 +++++ hermes_cli/commands.py | 95 +----- hermes_cli/slack_command_policy.py | 64 ++++ plugins/platforms/discord/adapter.py | 173 ++-------- plugins/platforms/discord/choice_picker.py | 187 +++++++++++ plugins/platforms/matrix/adapter.py | 124 +------ plugins/platforms/matrix/message_context.py | 127 +++++++ plugins/platforms/slack/adapter.py | 169 +--------- plugins/platforms/slack/slash_command.py | 171 ++++++++++ plugins/platforms/telegram/adapter.py | 145 ++------ plugins/platforms/telegram/choice_picker.py | 166 ++++++++++ .../test_api_server_room_controls.py | 17 +- tests/gateway/test_choice_picker.py | 1 - tests/gateway/test_desktop_room_mailbox.py | 152 +++++---- .../gateway/test_group_chat_matrix_adapter.py | 8 +- .../gateway/test_group_chat_slack_adapter.py | 76 ++--- .../test_hosted_room_control_client.py | 46 ++- tests/gateway/test_hosted_room_controls.py | 17 +- tests/gateway/test_hosted_room_messaging.py | 217 +++++++----- .../test_hosted_room_messaging_approvals.py | 126 ++++--- .../test_hosted_room_messaging_security.py | 146 ++++---- tests/gateway/test_slash_dispatch_logging.py | 50 +++ .../test_hosted_room_messaging_approvals.py | 40 +-- .../test_hosted_room_messaging_retry.py | 38 ++- tui_gateway/change_signatures.py | 67 ++++ tui_gateway/server.py | 64 +--- 43 files changed, 2386 insertions(+), 2017 deletions(-) create mode 100644 gateway/hosted_room_driver_schema.py create mode 100644 gateway/session_source.py create mode 100644 gateway/slash_dispatch.py create mode 100644 hermes_cli/slack_command_policy.py create mode 100644 plugins/platforms/discord/choice_picker.py create mode 100644 plugins/platforms/matrix/message_context.py create mode 100644 plugins/platforms/slack/slash_command.py create mode 100644 plugins/platforms/telegram/choice_picker.py create mode 100644 tests/gateway/test_slash_dispatch_logging.py create mode 100644 tui_gateway/change_signatures.py diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts index c0474ae33c6cc..81d438e7c24ba 100644 --- a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { - desktopRoomDescriptors, - desktopRoomIdentity, - runDesktopRoomCommandCycle -} from './desktop-room-command-client' +import { desktopRoomDescriptors, desktopRoomIdentity, runDesktopRoomCommandCycle } from './desktop-room-command-client' import type { GroupChat, ProfileRoute } from './types' const route = (connectionId: string): ProfileRoute => ({ diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts index 6ffede5313ebe..e7f66973ab2f1 100644 --- a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts @@ -156,7 +156,7 @@ export async function runDesktopRoomCommandCycle({ } const commands = Array.isArray((claimed as { commands?: unknown[] } | null)?.commands) - ? ((claimed as { commands: DesktopRoomCommand[] }).commands || []) + ? (claimed as { commands: DesktopRoomCommand[] }).commands || [] : [] remaining -= commands.length diff --git a/apps/desktop/src/plugins/hermes-bots/group-chat.ts b/apps/desktop/src/plugins/hermes-bots/group-chat.ts index 5d8cd8aeecdf8..d94bd750e308e 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-chat.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-chat.ts @@ -335,7 +335,7 @@ export function groupChatSyncSnapshot( ? { thread: String(entry.thread).slice(0, 128) } - : {}), + : {}) })) const compact: GroupChatSyncRoom = { @@ -1022,7 +1022,9 @@ export function durableGroupChatRooms(all: Record = $groupCha desktopCoordinatorId: typeof room.desktopCoordinatorId === 'string' && room.desktopCoordinatorId ? room.desktopCoordinatorId : null, desktopAuthorityToken: - typeof room.desktopAuthorityToken === 'string' && room.desktopAuthorityToken ? room.desktopAuthorityToken : null, + typeof room.desktopAuthorityToken === 'string' && room.desktopAuthorityToken + ? room.desktopAuthorityToken + : null, desktopCommandSettled: boundedDesktopCommandSettled(room.desktopCommandSettled), hosted: groupChatHostedGateway(room) || null, hostedEpoch: groupChatHostedEpoch(room) || null, @@ -1631,7 +1633,9 @@ export function updateGroupChat( desktopCoordinatorId: typeof room.desktopCoordinatorId === 'string' && room.desktopCoordinatorId ? room.desktopCoordinatorId : null, desktopAuthorityToken: - typeof room.desktopAuthorityToken === 'string' && room.desktopAuthorityToken ? room.desktopAuthorityToken : null, + typeof room.desktopAuthorityToken === 'string' && room.desktopAuthorityToken + ? room.desktopAuthorityToken + : null, desktopCommandSettled: boundedDesktopCommandSettled(room.desktopCommandSettled), hosted: groupChatHostedGateway(room) || null, hostedEpoch: groupChatHostedEpoch(room) || null, diff --git a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts index 91cdbb554d552..f0731477274a6 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-rounds.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-rounds.ts @@ -589,10 +589,7 @@ export async function stopGroupThread(group: string, thread: null | string, memb /** Fence a classic-room turn after this Desktop loses its gateway command * lease. Unlike a user Stop, this adds no durable holds, so the same command * can be leased to the current owner and resumed idempotently. */ -export async function cancelGroupThreadForLeaseLoss( - group: string, - members: GroupMember[] | null = null -) { +export async function cancelGroupThreadForLeaseLoss(group: string, members: GroupMember[] | null = null) { const room = $groupChats.get()[group] || {} const roster = Array.isArray(members) && members.length ? members : room.members || [] const turnName = room.turn || null @@ -1018,10 +1015,7 @@ export async function runGroupChatRounds(group: string, members: GroupMember[], // the round cap ended the drive, not consensus. (#94478) exitKind = 'capped' } finally { - const externalIds = (Array.isArray(($groupChats.get()[group] || {}).log) - ? $groupChats.get()[group].log - : [] - ) + const externalIds = (Array.isArray(($groupChats.get()[group] || {}).log) ? $groupChats.get()[group].log : []) .filter(entry => entry?.external && groupThreadOf(entry) === thread && entry?.id) .map(entry => String(entry.id)) @@ -1119,7 +1113,10 @@ export function sendToGroupChat( const hosted = groupChatHostedGateway(roomBeforeSend) const connectionName = hostedConnectionName(roomBeforeSend) const externalId = String(options.entryId || '').trim() - const userName = String(options.userName || 'You').trim().slice(0, 128) || 'You' + const userName = + String(options.userName || 'You') + .trim() + .slice(0, 128) || 'You' if ((!trimmed && !attached.length) || !members.length) { return null @@ -1154,7 +1151,9 @@ export function sendToGroupChat( const target = thread || mintGroupThreadId() if (externalId) { - const existing = (Array.isArray(roomBeforeSend?.log) ? roomBeforeSend.log : []).find(entry => entry?.id === externalId) + const existing = (Array.isArray(roomBeforeSend?.log) ? roomBeforeSend.log : []).find( + entry => entry?.id === externalId + ) if (existing) { const existingThread = existing.thread || 'legacy' diff --git a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts index 4cdd4372fae9b..ca9d67eace0a5 100644 --- a/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/hosted-room-runtime.test.ts @@ -311,14 +311,20 @@ describe('hosted Group Chat runtime', () => { it('replays the final events of a known room before marking a remote disband', async () => { const events = [ - hostedEvent(3, 'member-1', 'message.member', { - text: 'Finished while Desktop was closed', - thread_id: 'thread-1' - }, { - kind: 'member', - id: 'builder', - display_name: 'Builder' - }), + hostedEvent( + 3, + 'member-1', + 'message.member', + { + text: 'Finished while Desktop was closed', + thread_id: 'thread-1' + }, + { + kind: 'member', + id: 'builder', + display_name: 'Builder' + } + ), hostedEvent(4, 'disbanded-1', 'room.disbanded') ] @@ -334,16 +340,18 @@ describe('hosted Group Chat runtime', () => { if (method === 'groups.list') { return { - rooms: [{ - authority_epoch: 1, - authority_gateway_id: 'install:home', - disbanded_at: 4, - latest_seq: 4, - members: MEMBERS, - name: 'Release', - revision: 2, - room_id: 'room-1' - }] + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + } + ] } } @@ -383,14 +391,16 @@ describe('hosted Group Chat runtime', () => { loaded.chat.$groupChats.set({ Release: room({ hostedSeq: 2, - log: [{ - at: 2, - from: { kind: 'user', name: 'You' }, - id: 'user-1', - seq: 2, - text: 'Start', - thread: 'thread-1' - }] + log: [ + { + at: 2, + from: { kind: 'user', name: 'You' }, + id: 'user-1', + seq: 2, + text: 'Start', + thread: 'thread-1' + } + ] }) }) @@ -418,16 +428,18 @@ describe('hosted Group Chat runtime', () => { if (method === 'groups.list') { return { - rooms: [{ - authority_epoch: 1, - authority_gateway_id: 'install:home', - disbanded_at: 4, - latest_seq: 4, - members: MEMBERS, - name: 'Release', - revision: 2, - room_id: 'room-1' - }] + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + } + ] } } @@ -461,14 +473,16 @@ describe('hosted Group Chat runtime', () => { loaded.chat.$groupChats.set({ Release: room({ hostedSeq: 2, - log: [{ - at: 2, - from: { kind: 'user', name: 'You' }, - id: 'user-1', - seq: 2, - text: 'Start', - thread: 'thread-1' - }] + log: [ + { + at: 2, + from: { kind: 'user', name: 'You' }, + id: 'user-1', + seq: 2, + text: 'Start', + thread: 'thread-1' + } + ] }) }) @@ -491,16 +505,18 @@ describe('hosted Group Chat runtime', () => { if (method === 'groups.list') { return { - rooms: [{ - authority_epoch: 1, - authority_gateway_id: 'install:home', - disbanded_at: 4, - latest_seq: 4, - members: MEMBERS, - name: 'Release', - revision: 2, - room_id: 'room-1' - }] + rooms: [ + { + authority_epoch: 1, + authority_gateway_id: 'install:home', + disbanded_at: 4, + latest_seq: 4, + members: MEMBERS, + name: 'Release', + revision: 2, + room_id: 'room-1' + } + ] } } @@ -1099,9 +1115,10 @@ describe('hosted Group Chat runtime', () => { const calls = loaded.calls.filter(call => call.method === `groups.${kind}`) expect(calls).toHaveLength(2) - expect( - calls.map(call => String(call.params.event_id || call.params.cancel_id || '')) - ).toEqual([commandId, commandId]) + expect(calls.map(call => String(call.params.event_id || call.params.cancel_id || ''))).toEqual([ + commandId, + commandId + ]) expect(accepted).toBe(1) expect(loaded.storage.get('hosted-room-outbox-v1')).toMatchObject({ commands: [] diff --git a/gateway/desktop_room_mailbox.py b/gateway/desktop_room_mailbox.py index 91d6ebebf5635..fe4ecd8438821 100644 --- a/gateway/desktop_room_mailbox.py +++ b/gateway/desktop_room_mailbox.py @@ -210,9 +210,7 @@ def _initialize(conn: sqlite3.Connection) -> None: row[1] for row in conn.execute("PRAGMA table_info(desktop_room_authorities)") } if "consumer_id" not in authority_columns: - conn.execute( - "ALTER TABLE desktop_room_authorities ADD COLUMN consumer_id TEXT" - ) + conn.execute("ALTER TABLE desktop_room_authorities ADD COLUMN consumer_id TEXT") def _schema_is_current(conn: sqlite3.Connection) -> bool: @@ -222,9 +220,7 @@ def _schema_is_current(conn: sqlite3.Connection) -> bool: presence = { row[1] for row in conn.execute("PRAGMA table_info(desktop_room_presence)") } - owners = { - row[1] for row in conn.execute("PRAGMA table_info(desktop_room_owners)") - } + owners = {row[1] for row in conn.execute("PRAGMA table_info(desktop_room_owners)")} authorities = { row[1] for row in conn.execute("PRAGMA table_info(desktop_room_authorities)") } @@ -332,12 +328,10 @@ def _expire_stale_state( conn.execute("DELETE FROM desktop_room_presence WHERE expires_at <= ?", (now,)) conn.execute("DELETE FROM desktop_room_owners WHERE expires_at <= ?", (now,)) cutoff = now - max(1.0, float(pending_ttl)) - expired_result = _payload_json( - { - "code": "command_expired", - "message": "This Group Chat command expired before Desktop could apply it.", - } - ) + expired_result = _payload_json({ + "code": "command_expired", + "message": "This Group Chat command expired before Desktop could apply it.", + }) conn.execute( """UPDATE desktop_room_commands SET state = 'failed', result_json = ?, lease_owner = NULL, @@ -377,7 +371,10 @@ def _owned_rooms( FROM desktop_room_authorities WHERE room_id = ?""", (room_id,), ).fetchone() - if authority is None or str(authority["authority_hash"] or "") != authority_hash: + if ( + authority is None + or str(authority["authority_hash"] or "") != authority_hash + ): continue bound_consumer = str(authority["consumer_id"] or "") if bound_consumer and bound_consumer != consumer_id: @@ -435,10 +432,7 @@ def _owned_rooms( ) VALUES (?, ?, ?) ON CONFLICT(consumer_id, room_id) DO UPDATE SET expires_at = excluded.expires_at""", - ( - (consumer_id, room_id, now + float(presence_ttl)) - for room_id in owned - ), + ((consumer_id, room_id, now + float(presence_ttl)) for room_id in owned), ) return owned @@ -532,12 +526,10 @@ def enqueue_command( result = _command(existing, idempotent=True) else: if action == "stop": - superseded_result = _payload_json( - { - "code": "superseded_by_stop", - "message": "Canceled before Desktop started it.", - } - ) + superseded_result = _payload_json({ + "code": "superseded_by_stop", + "message": "Canceled before Desktop started it.", + }) conn.execute( """UPDATE desktop_room_commands SET state = 'failed', result_json = ?, lease_owner = NULL, @@ -680,7 +672,9 @@ def claim_commands( if target["result_json"] else {} ) - if isinstance(target_result, dict) and target_result.get("code"): + if isinstance(target_result, dict) and target_result.get( + "code" + ): command["target_result_code"] = str(target_result["code"]) claimed.append(command) return claimed @@ -1016,9 +1010,7 @@ def failed_command_counts( GROUP BY room_id""", tuple(batch), ).fetchall() - counts.update( - {str(row["room_id"]): int(row["count"]) for row in rows} - ) + counts.update({str(row["room_id"]): int(row["count"]) for row in rows}) return counts diff --git a/gateway/group_chat_slash.py b/gateway/group_chat_slash.py index 208410a0bfa3b..14dcf10d85327 100644 --- a/gateway/group_chat_slash.py +++ b/gateway/group_chat_slash.py @@ -115,9 +115,7 @@ def _census() -> bool: else: pairing_store_for = getattr(self, "_pairing_store_for", None) pairing_store = ( - pairing_store_for(source) - if callable(pairing_store_for) - else None + pairing_store_for(source) if callable(pairing_store_for) else None ) if pairing_store is not None: try: @@ -148,7 +146,6 @@ def _census() -> bool: with _profile_runtime_scope(Path(authorization_home)): return _census() - def _can_control_group_chats(self, event: MessageEvent) -> bool: """Authorize a trusted DM or the exact operator of an explicit home chat.""" from gateway.slash_access import policy_for_source @@ -159,14 +156,19 @@ def _can_control_group_chats(self, event: MessageEvent) -> bool: # m.direct rooms can contain several people. Adapters stamp this # transport-local signal only when they can prove the current surface is # one-to-one. Unknown and older connectors fail closed. - platform = str(getattr(getattr(event.source, "platform", None), "value", "") or "") + platform = str( + getattr(getattr(event.source, "platform", None), "value", "") or "" + ) native_distinct_dm = ( getattr(event.source, "delivered_via_upstream_relay", False) is not True and platform in _NATIVE_DISTINCT_DM_PLATFORMS and str(getattr(event.source, "chat_type", "") or "").casefold() in {"dm", "direct", "private"} ) - if getattr(event.source, "is_one_to_one", None) is not True and not native_distinct_dm: + if ( + getattr(event.source, "is_one_to_one", None) is not True + and not native_distinct_dm + ): return False chat_type = str(getattr(event.source, "chat_type", "") or "").casefold() if chat_type not in {"", "dm", "direct", "private"}: @@ -174,20 +176,18 @@ def _can_control_group_chats(self, event: MessageEvent) -> bool: policy = policy_for_source(self.config, event.source) return policy.enabled and policy.is_admin(event.source.user_id) - @staticmethod def _group_chat_control_denial(event: MessageEvent) -> str: chat_type = str(getattr(event.source, "chat_type", "") or "").casefold() - platform = str(getattr(getattr(event.source, "platform", None), "value", "") or "") + platform = str( + getattr(getattr(event.source, "platform", None), "value", "") or "" + ) proven_private = getattr(event.source, "is_one_to_one", None) is True or ( getattr(event.source, "delivered_via_upstream_relay", False) is not True and platform in _NATIVE_DISTINCT_DM_PLATFORMS and chat_type in {"dm", "direct", "private"} ) - if ( - chat_type not in {"", "dm", "direct", "private"} - or not proven_private - ): + if chat_type not in {"", "dm", "direct", "private"} or not proven_private: return ( "Group Chat controls are private. Use your authorized one-to-one " "Hermes chat." @@ -197,7 +197,6 @@ def _group_chat_control_denial(event: MessageEvent) -> str: "Hermes chat or authorize this account in settings." ) - def _group_chat_rate_limit_denial( self, event: MessageEvent, @@ -223,7 +222,11 @@ def _group_chat_rate_limit_denial( platform, str(getattr(source, "scope_id", None) or ""), str(getattr(source, "chat_id", None) or ""), - str(getattr(source, "user_id_alt", None) or getattr(source, "user_id", None) or ""), + str( + getattr(source, "user_id_alt", None) + or getattr(source, "user_id", None) + or "" + ), bucket_kind, ) now = time.monotonic() @@ -251,7 +254,6 @@ def _group_chat_rate_limit_denial( buckets.pop(next(iter(buckets))) return None - @staticmethod def _group_chat_profile(event: MessageEvent) -> str: """Return the profile selected by the authenticated inbound route.""" @@ -263,7 +265,6 @@ def _group_chat_profile(event: MessageEvent) -> str: return str(get_active_profile_name() or "default") - async def _handle_rooms_command(self, event: MessageEvent) -> Optional[str]: """List Bot Group Chats or show one chat's recent activity.""" @@ -306,7 +307,8 @@ async def _handle_rooms_command(self, event: MessageEvent) -> Optional[str]: words and words[0].isdecimal() and len(words) > 1 - and words[1].casefold() in { + and words[1].casefold() + in { "approve", "deny", "retry", @@ -410,7 +412,9 @@ async def _on_approval_selected(_chat_id: str, value: str) -> str: return str(exc) except Exception: logger.exception("Failed to apply Group Chat approval") - return "Couldn’t apply that approval. Check the Group Chat again." + return ( + "Couldn’t apply that approval. Check the Group Chat again." + ) picker_sent = bool(choices) and await self._try_send_choice_picker( event, @@ -435,7 +439,9 @@ async def _on_approval_selected(_chat_id: str, value: str) -> str: room = resolve_room(rooms, words[0]) if words[1].casefold() == "bot": if len(words) != 3: - return f"Use `{rooms_command} {words[0]} bot `." + return ( + f"Use `{rooms_command} {words[0]} bot `." + ) return await asyncio.to_thread( format_room_bot_detail, service, @@ -543,7 +549,9 @@ async def _on_room_selected(_chat_id: str, value: str) -> str: except (RoomControlError, hosted_rooms.HostedRoomError) as exc: return str(exc) except Exception: - logger.exception("Failed to open Group Chat from messaging picker") + logger.exception( + "Failed to open Group Chat from messaging picker" + ) return ( "Couldn’t load that Group Chat. " f"Run `{rooms_command}` again." @@ -579,7 +587,9 @@ async def _on_room_selected(_chat_id: str, value: str) -> str: ) list_parts = query.casefold().split() if not query or (list_parts and list_parts[0] == "list"): - if len(list_parts) > 2 or (len(list_parts) == 2 and not list_parts[1].isdecimal()): + if len(list_parts) > 2 or ( + len(list_parts) == 2 and not list_parts[1].isdecimal() + ): return f"Use `{rooms_command} list [page]`." page = int(list_parts[1]) if len(list_parts) == 2 else 1 return await asyncio.to_thread( @@ -605,7 +615,6 @@ def _detail() -> str: logger.exception("Failed to read Bot Group Chats from messaging") return "Couldn’t load Group Chats. Try again in a moment." - async def _handle_room_command(self, event: MessageEvent) -> str: """Send to or stop work in a Bot Group Chat.""" @@ -625,6 +634,7 @@ async def _handle_room_command(self, event: MessageEvent) -> str: messaging_event_id, relay_provenance_is_unknown, ) + if is_machine_authored(event): return "Group Chat controls are only available to people." if is_message_edit(event): @@ -654,9 +664,7 @@ async def _handle_room_command(self, event: MessageEvent) -> str: raise RoomControlError( f"Use `{rooms_command} send `." ) - raise RoomControlError( - f"Use `{rooms_command} stop`." - ) + raise RoomControlError(f"Use `{rooms_command} stop`.") def _mutate() -> str: rooms = list_messaging_rooms( @@ -679,7 +687,10 @@ def _mutate() -> str: service.db_path, command_id=approval_command_id, ) - if approval_receipt is not None and approval_receipt["state"] == "completed": + if ( + approval_receipt is not None + and approval_receipt["state"] == "completed" + ): return str( approval_receipt.get("result_text") or "Approval is no longer available." @@ -704,10 +715,9 @@ def _mutate() -> str: raise RoomControlError( "That approval is no longer available. Check Group Chats again." ) - if ( - room.get("_room_mode") == "desktop" - and str(room.get("room_id") or "").startswith("name:") - ): + if room.get("_room_mode") == "desktop" and str( + room.get("room_id") or "" + ).startswith("name:"): raise RoomControlError( "Open this older Group Chat once in the latest Hermes Desktop " "before changing it from messaging." diff --git a/gateway/hosted_room_controls.py b/gateway/hosted_room_controls.py index a8f2891182f14..92c88e75a0c26 100644 --- a/gateway/hosted_room_controls.py +++ b/gateway/hosted_room_controls.py @@ -241,16 +241,14 @@ def _derived_control_token( authority_epoch: int, request_id: str, ) -> str: - material = "\0".join( - ( - "hermes-room-control-v1", - room_id, - member_id, - authority_gateway_id, - str(authority_epoch), - request_id, - ) - ).encode("utf-8") + material = "\0".join(( + "hermes-room-control-v1", + room_id, + member_id, + authority_gateway_id, + str(authority_epoch), + request_id, + )).encode("utf-8") digest = hmac.new(gateway_room_grant_secret(), material, hashlib.sha256).digest() return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") @@ -339,47 +337,52 @@ def _schema_is_current(conn: sqlite3.Connection) -> bool: row[1] for row in conn.execute("PRAGMA table_info(hosted_room_peer_controls)") } commands = { - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_control_commands)") + row[1] + for row in conn.execute("PRAGMA table_info(hosted_room_control_commands)") } - return { - "room_id", - "member_id", - "authority_gateway_id", - "authority_epoch", - "request_id", - "token_hash", - "status", - "created_at", - "updated_at", - "expires_at", - "revoked_at", - }.issubset(home) and { - "room_id", - "member_id", - "room_name", - "member_count", - "home_url", - "transport_security", - "authority_gateway_id", - "authority_epoch", - "control_token", - "status", - "created_at", - "updated_at", - "expires_at", - "revoked_at", - "quarantine_reason", - }.issubset(peer) and { - "command_id", - "room_id", - "member_id", - "action", - "task_ids_json", - "state", - "result_json", - "created_at", - "updated_at", - }.issubset(commands) + return ( + { + "room_id", + "member_id", + "authority_gateway_id", + "authority_epoch", + "request_id", + "token_hash", + "status", + "created_at", + "updated_at", + "expires_at", + "revoked_at", + }.issubset(home) + and { + "room_id", + "member_id", + "room_name", + "member_count", + "home_url", + "transport_security", + "authority_gateway_id", + "authority_epoch", + "control_token", + "status", + "created_at", + "updated_at", + "expires_at", + "revoked_at", + "quarantine_reason", + }.issubset(peer) + and { + "command_id", + "room_id", + "member_id", + "action", + "task_ids_json", + "state", + "result_json", + "created_at", + "updated_at", + }.issubset(commands) + ) def _migrate_schema(conn: sqlite3.Connection) -> None: @@ -464,11 +467,11 @@ def _active_room_scope( if table is None: return False row = conn.execute( - """SELECT members_json FROM hosted_rooms + """SELECT members_json FROM hosted_rooms WHERE room_id=? AND authority_gateway_id=? AND authority_epoch=? AND disbanded_at IS NULL""", - (room_id, authority_gateway_id, authority_epoch), - ).fetchone() + (room_id, authority_gateway_id, authority_epoch), + ).fetchone() if row is None: return False if member_id is None: @@ -712,9 +715,7 @@ def revoke_home_control_token_value( room_id = _identifier(room_id, label="room_id") member_id = _identifier(member_id, label="member_id") - token_hash = hashlib.sha256( - _control_token(control_token).encode("ascii") - ).digest() + token_hash = hashlib.sha256(_control_token(control_token).encode("ascii")).digest() timestamp = _timestamp(time.time() if now is None else now, label="now") with _transaction(db_path, immediate=True) as conn: rows = conn.execute( @@ -1223,8 +1224,7 @@ def load_pending_control_retries( if not isinstance(raw_task_ids, list): raise HostedRoomControlError("stored retry task_ids are invalid") task_ids = tuple( - _identifier(value, label="task_id") - for value in raw_task_ids + _identifier(value, label="task_id") for value in raw_task_ids ) if ( not task_ids @@ -1234,13 +1234,9 @@ def load_pending_control_retries( raise HostedRoomControlError("stored retry task_ids are invalid") pending.append( PendingRoomControlRetry( - command_id=_identifier( - row["command_id"], label="command_id" - ), + command_id=_identifier(row["command_id"], label="command_id"), room_id=_identifier(row["room_id"], label="room_id"), - member_id=_identifier( - row["member_id"], label="member_id" - ), + member_id=_identifier(row["member_id"], label="member_id"), task_ids=task_ids, ) ) diff --git a/gateway/hosted_room_driver.py b/gateway/hosted_room_driver.py index 06681dc51f805..0af0c657629af 100644 --- a/gateway/hosted_room_driver.py +++ b/gateway/hosted_room_driver.py @@ -18,6 +18,12 @@ from pathlib import Path from typing import Any, Callable, Iterator, Literal +from gateway import hosted_room_driver_schema as _driver_schema +from gateway.hosted_room_driver_schema import ( + DriverStateError, + _TASK_COLUMN_ORDER as _TASK_COLUMN_ORDER, +) + Clock = Callable[[], float] TaskStatus = Literal[ @@ -51,73 +57,12 @@ TERMINAL_STATUSES = frozenset({"settled", "failed", "cancelled"}) _IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") -_TASK_PAYLOAD_REQUIRED_FIELDS = frozenset( - {"target_profile", "prompt", "source_event_seq"} -) -_TASK_PAYLOAD_OPTIONAL_FIELDS = frozenset({"target_member_id"}) -_LEASE_COLUMNS = frozenset({ - "room_id", - "gateway_id", - "authority_epoch", - "process_generation", - "lease_generation", - "expires_at", - "acquired_at", - "updated_at", - "released_at", -}) -_TASK_COLUMNS = frozenset({ - "room_id", - "task_id", - "thread_id", - "turn_id", +_TASK_PAYLOAD_REQUIRED_FIELDS = frozenset({ + "target_profile", + "prompt", "source_event_seq", - "payload_json", - "payload_digest", - "status", - "execution_generation", - "cancel_generation", - "run_gateway_id", - "run_process_generation", - "run_lease_generation", - "cancel_id", - "settlement_id", - "settlement_status", - "result_json", - "created_at", - "updated_at", - "started_at", - "terminal_at", - "indeterminate_at", }) -_TASK_COLUMN_ORDER = ( - "room_id", - "task_id", - "thread_id", - "turn_id", - "source_event_seq", - "payload_json", - "payload_digest", - "status", - "execution_generation", - "cancel_generation", - "run_gateway_id", - "run_process_generation", - "run_lease_generation", - "cancel_id", - "settlement_id", - "settlement_status", - "result_json", - "created_at", - "updated_at", - "started_at", - "terminal_at", - "indeterminate_at", -) - - -class DriverStateError(ValueError): - """Base class for invalid or conflicting driver-state operations.""" +_TASK_PAYLOAD_OPTIONAL_FIELDS = frozenset({"target_member_id"}) class DriverValidationError(DriverStateError): @@ -216,11 +161,7 @@ def _authority_epoch(value: Any) -> int: def _task_payload(value: Any) -> tuple[dict[str, Any], str, str]: if not isinstance(value, dict): raise DriverValidationError("payload must be an object") - unknown = ( - set(value) - - _TASK_PAYLOAD_REQUIRED_FIELDS - - _TASK_PAYLOAD_OPTIONAL_FIELDS - ) + unknown = set(value) - _TASK_PAYLOAD_REQUIRED_FIELDS - _TASK_PAYLOAD_OPTIONAL_FIELDS missing = _TASK_PAYLOAD_REQUIRED_FIELDS - set(value) if unknown: raise DriverValidationError( @@ -307,219 +248,6 @@ class TaskAttempt: cancel_generation: int -def _create_task_table( - conn: sqlite3.Connection, table: str = "hosted_room_driver_tasks" -) -> None: - if table not in {"hosted_room_driver_tasks", "hosted_room_driver_tasks_next"}: - raise DriverStateError("invalid hosted-room task table name") - conn.execute( - f"""CREATE TABLE IF NOT EXISTS {table} ( - room_id TEXT NOT NULL, - task_id TEXT NOT NULL, - thread_id TEXT NOT NULL, - turn_id TEXT NOT NULL, - source_event_seq INTEGER NOT NULL CHECK (source_event_seq >= 1), - payload_json TEXT NOT NULL, - payload_digest TEXT NOT NULL, - status TEXT NOT NULL CHECK ( - status IN ( - 'queued', 'running', 'settled', 'failed', - 'cancelled', 'indeterminate', 'deferred', 'stopping' - ) - ), - execution_generation INTEGER NOT NULL DEFAULT 0 - CHECK (execution_generation >= 0), - cancel_generation INTEGER NOT NULL DEFAULT 0 - CHECK (cancel_generation >= 0), - run_gateway_id TEXT, - run_process_generation TEXT, - run_lease_generation INTEGER, - cancel_id TEXT, - settlement_id TEXT, - settlement_status TEXT, - result_json TEXT, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - started_at REAL, - terminal_at REAL, - indeterminate_at REAL, - PRIMARY KEY (room_id, task_id), - UNIQUE (room_id, thread_id, turn_id), - FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id) - )""" - ) - - -def _initialize_retry_receipt_table(conn: sqlite3.Connection) -> None: - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_retry_receipts ( - retry_id TEXT PRIMARY KEY, - room_id TEXT NOT NULL, - task_id TEXT NOT NULL, - source_execution_generation INTEGER NOT NULL - CHECK (source_execution_generation >= 0), - created_at REAL NOT NULL, - FOREIGN KEY (room_id, task_id) - REFERENCES hosted_room_driver_tasks(room_id, task_id) - ON DELETE CASCADE - )""" - ) - - -def _initialize_schema(conn: sqlite3.Connection) -> None: - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_driver_leases ( - room_id TEXT PRIMARY KEY, - gateway_id TEXT NOT NULL, - authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), - process_generation TEXT NOT NULL, - lease_generation INTEGER NOT NULL CHECK (lease_generation >= 1), - expires_at REAL NOT NULL, - acquired_at REAL NOT NULL, - updated_at REAL NOT NULL, - released_at REAL, - FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id) - )""" - ) - _create_task_table(conn) - _initialize_retry_receipt_table(conn) - _validate_schema(conn) - conn.execute( - """CREATE INDEX IF NOT EXISTS idx_hosted_room_driver_tasks_status - ON hosted_room_driver_tasks( - room_id, status, source_event_seq, created_at, task_id - )""" - ) - - -def _validate_schema(conn: sqlite3.Connection) -> None: - lease_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_driver_leases)") - ) - task_columns = frozenset( - row[1] for row in conn.execute("PRAGMA table_info(hosted_room_driver_tasks)") - ) - if lease_columns != _LEASE_COLUMNS or task_columns != _TASK_COLUMNS: - raise DriverStateError( - "unsupported unpublished hosted-room driver schema; " - "recreate the driver tables before starting the driver" - ) - - for table in ("hosted_room_driver_leases", "hosted_room_driver_tasks"): - foreign_keys = conn.execute(f"PRAGMA foreign_key_list({table})").fetchall() - if not any( - row[2] == "hosted_rooms" and row[3] == "room_id" and row[4] == "room_id" - for row in foreign_keys - ): - raise DriverStateError(f"{table} is missing its hosted_rooms foreign key") - - -def _schema_objects_exist(conn: sqlite3.Connection) -> bool: - rows = conn.execute( - """SELECT name FROM sqlite_master - WHERE type='table' AND name IN ( - 'hosted_room_driver_leases', 'hosted_room_driver_tasks' - )""" - ).fetchall() - tables = {row[0] for row in rows} - if tables != {"hosted_room_driver_leases", "hosted_room_driver_tasks"}: - return False - index = conn.execute( - """SELECT 1 FROM sqlite_master - WHERE type='index' AND name='idx_hosted_room_driver_tasks_status'""" - ).fetchone() - return index is not None - - -def _task_schema_supports_current_statuses(conn: sqlite3.Connection) -> bool: - row = conn.execute( - """SELECT sql FROM sqlite_master - WHERE type='table' AND name='hosted_room_driver_tasks'""" - ).fetchone() - sql = str(row[0] or "").lower() if row else "" - return "'stopping'" in sql and "'deferred'" in sql - - -def _task_schema_has_legacy_retry_id(conn: sqlite3.Connection) -> bool: - return any( - row[1] == "retry_id" - for row in conn.execute("PRAGMA table_info(hosted_room_driver_tasks)") - ) - - -def _migrate_task_status_constraint(conn: sqlite3.Connection) -> None: - """Expand the unpublished task-state CHECK without losing durable work.""" - preserved_receipts = [] - receipt_table = conn.execute( - """SELECT 1 FROM sqlite_master - WHERE type='table' AND name='hosted_room_retry_receipts'""" - ).fetchone() - if receipt_table is not None: - preserved_receipts.extend( - conn.execute( - """SELECT retry_id, room_id, task_id, - source_execution_generation, created_at - FROM hosted_room_retry_receipts""" - ).fetchall() - ) - if _task_schema_has_legacy_retry_id(conn): - preserved_receipts.extend( - conn.execute( - """SELECT retry_id, room_id, task_id, execution_generation, updated_at - FROM hosted_room_driver_tasks - WHERE retry_id IS NOT NULL AND retry_id != ''""" - ).fetchall() - ) - conn.execute("DROP TABLE IF EXISTS hosted_room_retry_receipts") - conn.execute("DROP INDEX IF EXISTS idx_hosted_room_driver_tasks_status") - _create_task_table(conn, "hosted_room_driver_tasks_next") - columns = ", ".join(_TASK_COLUMN_ORDER) - conn.execute( - f"""INSERT INTO hosted_room_driver_tasks_next ({columns}) - SELECT {columns} FROM hosted_room_driver_tasks""" - ) - conn.execute("DROP TABLE hosted_room_driver_tasks") - conn.execute( - "ALTER TABLE hosted_room_driver_tasks_next RENAME TO hosted_room_driver_tasks" - ) - conn.execute( - """CREATE INDEX idx_hosted_room_driver_tasks_status - ON hosted_room_driver_tasks( - room_id, status, source_event_seq, created_at, task_id - )""" - ) - _initialize_retry_receipt_table(conn) - for receipt in preserved_receipts: - existing = conn.execute( - "SELECT room_id, task_id FROM hosted_room_retry_receipts WHERE retry_id=?", - (str(receipt["retry_id"]),), - ).fetchone() - if existing is not None and ( - str(existing["room_id"]), str(existing["task_id"]) - ) != (str(receipt["room_id"]), str(receipt["task_id"])): - raise DriverStateError("draft retry_id is bound to multiple tasks") - conn.execute( - """INSERT OR IGNORE INTO hosted_room_retry_receipts( - retry_id, room_id, task_id, source_execution_generation, created_at - ) VALUES (?, ?, ?, ?, ?)""", - ( - str(receipt["retry_id"]), - str(receipt["room_id"]), - str(receipt["task_id"]), - int( - receipt["source_execution_generation"] - if "source_execution_generation" in receipt.keys() - else receipt["execution_generation"] - ), - float( - receipt["created_at"] - if "created_at" in receipt.keys() - else receipt["updated_at"] - ), - ), - ) - - def _connect(db_path: Path | str) -> sqlite3.Connection: from hermes_state import apply_wal_with_fallback @@ -530,23 +258,22 @@ def _connect(db_path: Path | str) -> sqlite3.Connection: try: apply_wal_with_fallback(conn, db_label="state.db (hosted_room_driver)") conn.execute("PRAGMA foreign_keys=ON") - if _schema_objects_exist(conn): - if ( - _task_schema_has_legacy_retry_id(conn) - or not _task_schema_supports_current_statuses(conn) - ): + if _driver_schema._schema_objects_exist(conn): + if _driver_schema._task_schema_has_legacy_retry_id( + conn + ) or not _driver_schema._task_schema_supports_current_statuses(conn): conn.execute("BEGIN IMMEDIATE") - _migrate_task_status_constraint(conn) + _driver_schema._migrate_task_status_constraint(conn) conn.commit() - _initialize_retry_receipt_table(conn) + _driver_schema._initialize_retry_receipt_table(conn) conn.commit() - _validate_schema(conn) + _driver_schema._validate_schema(conn) return conn # Schema creation is one database-wide transaction. The driver schema # has never shipped, so an incompatible draft schema fails closed # instead of attempting a partial in-place migration. conn.execute("BEGIN IMMEDIATE") - _initialize_schema(conn) + _driver_schema._initialize_schema(conn) conn.commit() except Exception: conn.rollback() diff --git a/gateway/hosted_room_driver_schema.py b/gateway/hosted_room_driver_schema.py new file mode 100644 index 0000000000000..e19f8a97a1fe3 --- /dev/null +++ b/gateway/hosted_room_driver_schema.py @@ -0,0 +1,288 @@ +"""SQLite schema ownership for the hosted Group Chat driver.""" + +from __future__ import annotations + +import sqlite3 + + +_LEASE_COLUMNS = frozenset({ + "room_id", + "gateway_id", + "authority_epoch", + "process_generation", + "lease_generation", + "expires_at", + "acquired_at", + "updated_at", + "released_at", +}) + + +_TASK_COLUMNS = frozenset({ + "room_id", + "task_id", + "thread_id", + "turn_id", + "source_event_seq", + "payload_json", + "payload_digest", + "status", + "execution_generation", + "cancel_generation", + "run_gateway_id", + "run_process_generation", + "run_lease_generation", + "cancel_id", + "settlement_id", + "settlement_status", + "result_json", + "created_at", + "updated_at", + "started_at", + "terminal_at", + "indeterminate_at", +}) + + +_TASK_COLUMN_ORDER = ( + "room_id", + "task_id", + "thread_id", + "turn_id", + "source_event_seq", + "payload_json", + "payload_digest", + "status", + "execution_generation", + "cancel_generation", + "run_gateway_id", + "run_process_generation", + "run_lease_generation", + "cancel_id", + "settlement_id", + "settlement_status", + "result_json", + "created_at", + "updated_at", + "started_at", + "terminal_at", + "indeterminate_at", +) + + +class DriverStateError(ValueError): + """Base class for invalid or conflicting driver-state operations.""" + + +def _create_task_table( + conn: sqlite3.Connection, table: str = "hosted_room_driver_tasks" +) -> None: + if table not in {"hosted_room_driver_tasks", "hosted_room_driver_tasks_next"}: + raise DriverStateError("invalid hosted-room task table name") + conn.execute( + f"""CREATE TABLE IF NOT EXISTS {table} ( + room_id TEXT NOT NULL, + task_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + source_event_seq INTEGER NOT NULL CHECK (source_event_seq >= 1), + payload_json TEXT NOT NULL, + payload_digest TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ( + 'queued', 'running', 'settled', 'failed', + 'cancelled', 'indeterminate', 'deferred', 'stopping' + ) + ), + execution_generation INTEGER NOT NULL DEFAULT 0 + CHECK (execution_generation >= 0), + cancel_generation INTEGER NOT NULL DEFAULT 0 + CHECK (cancel_generation >= 0), + run_gateway_id TEXT, + run_process_generation TEXT, + run_lease_generation INTEGER, + cancel_id TEXT, + settlement_id TEXT, + settlement_status TEXT, + result_json TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + started_at REAL, + terminal_at REAL, + indeterminate_at REAL, + PRIMARY KEY (room_id, task_id), + UNIQUE (room_id, thread_id, turn_id), + FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id) + )""" + ) + + +def _initialize_retry_receipt_table(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_retry_receipts ( + retry_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + task_id TEXT NOT NULL, + source_execution_generation INTEGER NOT NULL + CHECK (source_execution_generation >= 0), + created_at REAL NOT NULL, + FOREIGN KEY (room_id, task_id) + REFERENCES hosted_room_driver_tasks(room_id, task_id) + ON DELETE CASCADE + )""" + ) + + +def _initialize_schema(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_driver_leases ( + room_id TEXT PRIMARY KEY, + gateway_id TEXT NOT NULL, + authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), + process_generation TEXT NOT NULL, + lease_generation INTEGER NOT NULL CHECK (lease_generation >= 1), + expires_at REAL NOT NULL, + acquired_at REAL NOT NULL, + updated_at REAL NOT NULL, + released_at REAL, + FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id) + )""" + ) + _create_task_table(conn) + _initialize_retry_receipt_table(conn) + _validate_schema(conn) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_hosted_room_driver_tasks_status + ON hosted_room_driver_tasks( + room_id, status, source_event_seq, created_at, task_id + )""" + ) + + +def _validate_schema(conn: sqlite3.Connection) -> None: + lease_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_driver_leases)") + ) + task_columns = frozenset( + row[1] for row in conn.execute("PRAGMA table_info(hosted_room_driver_tasks)") + ) + if lease_columns != _LEASE_COLUMNS or task_columns != _TASK_COLUMNS: + raise DriverStateError( + "unsupported unpublished hosted-room driver schema; " + "recreate the driver tables before starting the driver" + ) + + for table in ("hosted_room_driver_leases", "hosted_room_driver_tasks"): + foreign_keys = conn.execute(f"PRAGMA foreign_key_list({table})").fetchall() + if not any( + row[2] == "hosted_rooms" and row[3] == "room_id" and row[4] == "room_id" + for row in foreign_keys + ): + raise DriverStateError(f"{table} is missing its hosted_rooms foreign key") + + +def _schema_objects_exist(conn: sqlite3.Connection) -> bool: + rows = conn.execute( + """SELECT name FROM sqlite_master + WHERE type='table' AND name IN ( + 'hosted_room_driver_leases', 'hosted_room_driver_tasks' + )""" + ).fetchall() + tables = {row[0] for row in rows} + if tables != {"hosted_room_driver_leases", "hosted_room_driver_tasks"}: + return False + index = conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='index' AND name='idx_hosted_room_driver_tasks_status'""" + ).fetchone() + return index is not None + + +def _task_schema_supports_current_statuses(conn: sqlite3.Connection) -> bool: + row = conn.execute( + """SELECT sql FROM sqlite_master + WHERE type='table' AND name='hosted_room_driver_tasks'""" + ).fetchone() + sql = str(row[0] or "").lower() if row else "" + return "'stopping'" in sql and "'deferred'" in sql + + +def _task_schema_has_legacy_retry_id(conn: sqlite3.Connection) -> bool: + return any( + row[1] == "retry_id" + for row in conn.execute("PRAGMA table_info(hosted_room_driver_tasks)") + ) + + +def _migrate_task_status_constraint(conn: sqlite3.Connection) -> None: + """Expand the unpublished task-state CHECK without losing durable work.""" + preserved_receipts = [] + receipt_table = conn.execute( + """SELECT 1 FROM sqlite_master + WHERE type='table' AND name='hosted_room_retry_receipts'""" + ).fetchone() + if receipt_table is not None: + preserved_receipts.extend( + conn.execute( + """SELECT retry_id, room_id, task_id, + source_execution_generation, created_at + FROM hosted_room_retry_receipts""" + ).fetchall() + ) + if _task_schema_has_legacy_retry_id(conn): + preserved_receipts.extend( + conn.execute( + """SELECT retry_id, room_id, task_id, execution_generation, updated_at + FROM hosted_room_driver_tasks + WHERE retry_id IS NOT NULL AND retry_id != ''""" + ).fetchall() + ) + conn.execute("DROP TABLE IF EXISTS hosted_room_retry_receipts") + conn.execute("DROP INDEX IF EXISTS idx_hosted_room_driver_tasks_status") + _create_task_table(conn, "hosted_room_driver_tasks_next") + columns = ", ".join(_TASK_COLUMN_ORDER) + conn.execute( + f"""INSERT INTO hosted_room_driver_tasks_next ({columns}) + SELECT {columns} FROM hosted_room_driver_tasks""" + ) + conn.execute("DROP TABLE hosted_room_driver_tasks") + conn.execute( + "ALTER TABLE hosted_room_driver_tasks_next RENAME TO hosted_room_driver_tasks" + ) + conn.execute( + """CREATE INDEX idx_hosted_room_driver_tasks_status + ON hosted_room_driver_tasks( + room_id, status, source_event_seq, created_at, task_id + )""" + ) + _initialize_retry_receipt_table(conn) + for receipt in preserved_receipts: + existing = conn.execute( + "SELECT room_id, task_id FROM hosted_room_retry_receipts WHERE retry_id=?", + (str(receipt["retry_id"]),), + ).fetchone() + if existing is not None and ( + str(existing["room_id"]), + str(existing["task_id"]), + ) != (str(receipt["room_id"]), str(receipt["task_id"])): + raise DriverStateError("draft retry_id is bound to multiple tasks") + conn.execute( + """INSERT OR IGNORE INTO hosted_room_retry_receipts( + retry_id, room_id, task_id, source_execution_generation, created_at + ) VALUES (?, ?, ?, ?, ?)""", + ( + str(receipt["retry_id"]), + str(receipt["room_id"]), + str(receipt["task_id"]), + int( + receipt["source_execution_generation"] + if "source_execution_generation" in receipt.keys() + else receipt["execution_generation"] + ), + float( + receipt["created_at"] + if "created_at" in receipt.keys() + else receipt["updated_at"] + ), + ), + ) diff --git a/gateway/hosted_room_messaging.py b/gateway/hosted_room_messaging.py index f52f33c6d30bf..fc9a19348997b 100644 --- a/gateway/hosted_room_messaging.py +++ b/gateway/hosted_room_messaging.py @@ -72,9 +72,8 @@ def _projected_desktop_rooms(*, profile: str = "default") -> list[dict[str, Any] continue name = _clean_line(raw_room.get("name") or key, limit=200) explicit_room_id = str(raw_room.get("roomId") or "").strip() - room_id = ( - explicit_room_id - or (str(key).strip() if snapshot_version >= 3 else f"name:{name}") + room_id = explicit_room_id or ( + str(key).strip() if snapshot_version >= 3 else f"name:{name}" ) if ( not name @@ -87,7 +86,11 @@ def _projected_desktop_rooms(*, profile: str = "default") -> list[dict[str, Any] if not re.fullmatch(r"[a-f0-9]{64}", authority_hash): authority_hash = "" raw_log = raw_room.get("log") - log = [dict(item) for item in raw_log if isinstance(item, Mapping)] if isinstance(raw_log, list) else [] + log = ( + [dict(item) for item in raw_log if isinstance(item, Mapping)] + if isinstance(raw_log, list) + else [] + ) raw_members = raw_room.get("members") members = ( [dict(item) for item in raw_members if isinstance(item, Mapping)] @@ -98,21 +101,19 @@ def _projected_desktop_rooms(*, profile: str = "default") -> list[dict[str, Any] (float(item.get("at") or 0) for item in log), default=float(snapshot.get("updatedAt") or 0) / 1000, ) - rooms.append( - { - "room_id": room_id, - "name": name, - "members": members, - "log": log, - "created_at": min( - (float(item.get("at") or 0) for item in log), - default=updated_at, - ), - "updated_at": updated_at, - "desktop_authority_hash": authority_hash, - "_room_mode": "desktop", - } - ) + rooms.append({ + "room_id": room_id, + "name": name, + "members": members, + "log": log, + "created_at": min( + (float(item.get("at") or 0) for item in log), + default=updated_at, + ), + "updated_at": updated_at, + "desktop_authority_hash": authority_hash, + "_room_mode": "desktop", + }) return rooms @@ -192,20 +193,18 @@ def list_messaging_rooms( ): continue remote_ids.add(link.room_id) - remote.append( - { - "room_id": link.room_id, - "name": link.room_name, - "members": [], - "member_count": link.member_count, - "authority_gateway_id": link.authority_gateway_id, - "authority_epoch": link.authority_epoch, - "created_at": link.created_at, - "updated_at": link.updated_at, - "_remote_member_id": link.member_id, - "_room_mode": "remote", - } - ) + remote.append({ + "room_id": link.room_id, + "name": link.room_name, + "members": [], + "member_count": link.member_count, + "authority_gateway_id": link.authority_gateway_id, + "authority_epoch": link.authority_epoch, + "created_at": link.created_at, + "updated_at": link.updated_at, + "_remote_member_id": link.member_id, + "_room_mode": "remote", + }) desktop = [ room for room in _projected_desktop_rooms(profile=profile) @@ -310,10 +309,7 @@ def list_messaging_rooms( finally: conn.close() - return [ - {**room, "messaging_ref": refs[str(room["room_id"])]} - for room in rooms - ] + return [{**room, "messaging_ref": refs[str(room["room_id"])]} for room in rooms] def room_reference(room: Mapping[str, Any]) -> str: @@ -424,28 +420,24 @@ def room_picker_choices( name = _clean_line(room.get("name") or room.get("room_id"), limit=42) status = _room_status(service, room) count = _room_member_count(room) - choices.append( - { - "value": _room_picker_value(room), - "label": ( - f"{_room_status_icon(status)} {reference}. " - f"{_picker_display_label(name, limit=42)} ({count})" - ), - "full_width": True, - "is_current": False, - } - ) + choices.append({ + "value": _room_picker_value(room), + "label": ( + f"{_room_status_icon(status)} {reference}. " + f"{_picker_display_label(name, limit=42)} ({count})" + ), + "full_width": True, + "is_current": False, + }) return choices def _room_picker_value(room: Mapping[str, Any]) -> str: - seed = ":".join( - ( - str(room.get("_room_mode") or "hosted"), - str(room.get("connection_id") or room.get("_connection_id") or ""), - str(room.get("room_id") or ""), - ) - ) + seed = ":".join(( + str(room.get("_room_mode") or "hosted"), + str(room.get("connection_id") or room.get("_connection_id") or ""), + str(room.get("room_id") or ""), + )) return f"room-{hashlib.sha256(seed.encode()).hexdigest()[:16]}" @@ -457,7 +449,9 @@ def resolve_room_picker_choice( selected = [room for room in rooms if _room_picker_value(room) == str(value)] if len(selected) != 1: - raise RoomControlError("This Group Chat is no longer available. Run the command again.") + raise RoomControlError( + "This Group Chat is no longer available. Run the command again." + ) return selected[0] @@ -512,14 +506,14 @@ def _room_member_picker_value( return f"p={hashlib.sha256(seed.encode()).hexdigest()[:16]}" -def _room_display_members(service: Any, room: Mapping[str, Any]) -> list[Mapping[str, Any]]: +def _room_display_members( + service: Any, room: Mapping[str, Any] +) -> list[Mapping[str, Any]]: if room.get("_room_mode") == "remote": summary = _remote_summary(service, room) remote_room = summary.get("room") raw_members = ( - remote_room.get("members") - if isinstance(remote_room, Mapping) - else None + remote_room.get("members") if isinstance(remote_room, Mapping) else None ) else: raw_members = room.get("members") @@ -558,15 +552,13 @@ def room_bot_picker_choices( for member in _room_display_members(service, room): name = _room_member_name(member) handle = _room_member_handle(member) - choices.append( - { - "value": _room_member_picker_value(room, member), - "label": f"🤖 {_picker_display_label(name, limit=48)}" - + (f" · {handle}" if handle else ""), - "full_width": True, - "is_current": False, - } - ) + choices.append({ + "value": _room_member_picker_value(room, member), + "label": f"🤖 {_picker_display_label(name, limit=48)}" + + (f" · {handle}" if handle else ""), + "full_width": True, + "is_current": False, + }) return choices @@ -592,15 +584,13 @@ def format_room_bot_list( ) if not members: lines.append("No Bots are available in this Group Chat.") - lines.extend( - [ - "", - "────────", - "🧭 **Controls**", - f"Bot details: `{room_command} {reference} bot `", - f"Back to Group Chat: `{room_command} {reference}`", - ] - ) + lines.extend([ + "", + "────────", + "🧭 **Controls**", + f"Bot details: `{room_command} {reference} bot `", + f"Back to Group Chat: `{room_command} {reference}`", + ]) return "\n".join(lines) @@ -644,7 +634,9 @@ def format_room_bot_detail( if len(matches) == 1: selected = matches[0] if selected is None: - raise RoomControlError("No Bot in this Group Chat matches that number or handle.") + raise RoomControlError( + "No Bot in this Group Chat matches that number or handle." + ) room_name = _clean_line(room.get("name") or room.get("room_id"), limit=72) reference = room_reference(room) @@ -661,12 +653,10 @@ def format_room_bot_detail( lines.append( f"Message this Bot: `{room_command} {reference} send @{handle} `" ) - lines.extend( - [ - f"All Bots: `{room_command} {reference} bots`", - f"Back to Group Chat: `{room_command} {reference}`", - ] - ) + lines.extend([ + f"All Bots: `{room_command} {reference} bots`", + f"Back to Group Chat: `{room_command} {reference}`", + ]) return "\n".join(lines) @@ -745,8 +735,7 @@ def _remote_summary(service: Any, room: Mapping[str, Any]) -> dict[str, Any]: raise RoomControlError("This Group Chat returned invalid status data.") from exc if ( str(raw_room.get("room_id") or "") != link.room_id - or str(raw_room.get("authority_gateway_id") or "") - != link.authority_gateway_id + or str(raw_room.get("authority_gateway_id") or "") != link.authority_gateway_id or authority_epoch != link.authority_epoch ): raise RoomControlError("This Group Chat returned mismatched status data.") @@ -941,7 +930,9 @@ def status(self, room_id: str) -> dict[str, Any]: except Exception: peer_needs_attention = True return { - "working": any(counts.get(status) for status in ("queued", "running", "stopping")), + "working": any( + counts.get(status) for status in ("queued", "running", "stopping") + ), "blocked": bool(counts.get("indeterminate") or peer_needs_attention), "counts": counts, "pending_actions": [ @@ -1040,8 +1031,7 @@ def stop_room(self, room_id: str, *, cancel_id: str) -> int: elif status == "stopping": requested += 1 elif ( - status == "cancelled" - and current.get("cancel_id") == cancel_id + status == "cancelled" and current.get("cancel_id") == cancel_id ): requested += 1 break @@ -1076,7 +1066,9 @@ def current_room_backend() -> MessagingRoomBackend: service = get_hosted_room_service() return MessagingRoomBackend( - db_path=service.db_path if service is not None else hosted_rooms.default_db_path(), + db_path=service.db_path + if service is not None + else hosted_rooms.default_db_path(), service=service, ) @@ -1104,23 +1096,21 @@ def _plain_preview_text(value: Any, *, limit: int = MAX_PREVIEW_CHARS) -> str: text = _clean_line(value, limit=limit) return text.translate( - str.maketrans( - { - "@": "@", - "\\": "\", - "`": "`", - "*": "*", - "_": "_", - "{": "{", - "}": "}", - "[": "[", - "]": "]", - "#": "#", - "|": "|", - ">": ">", - "~": "~", - } - ) + str.maketrans({ + "@": "@", + "\\": "\", + "`": "`", + "*": "*", + "_": "_", + "{": "{", + "}": "}", + "[": "[", + "]": "]", + "#": "#", + "|": "|", + ">": ">", + "~": "~", + }) ) @@ -1142,9 +1132,7 @@ def parse_room_command(args: str, *, command_root: str = "/group") -> RoomComman if len(message) >= 2 and message[0] == message[-1] and message[0] in {'"', "'"}: message = message[1:-1].strip() if not message: - raise RoomControlError( - f"Use `{command_root} send `." - ) + raise RoomControlError(f"Use `{command_root} send `.") return RoomCommand("send", room_query, message) if action == "stop": if remainder: @@ -1176,9 +1164,7 @@ def resolve_room(rooms: list[dict[str, Any]], query: str) -> dict[str, Any]: if needle.isdecimal(): numeric_ref = int(needle) - matches = [ - room for room in rooms if room.get("messaging_ref") == numeric_ref - ] + matches = [room for room in rooms if room.get("messaging_ref") == numeric_ref] if len(matches) == 1: return matches[0] raise RoomControlError(f"No Group Chat is numbered {numeric_ref}.") @@ -1284,7 +1270,9 @@ def _room_action_flags( if room.get("_room_mode") == "desktop": command = room.get("desktop_command") state = str(command.get("state") or "") if isinstance(command, Mapping) else "" - action = str(command.get("action") or "") if isinstance(command, Mapping) else "" + action = ( + str(command.get("action") or "") if isinstance(command, Mapping) else "" + ) return ( int(room.get("desktop_failed_commands") or 0) > 0, action == "send" and state in {"claimed", "pending"}, @@ -1328,7 +1316,11 @@ def format_room_list( raise RoomControlError(f"There are only {page_count} Group Chat pages.") start = (page - 1) * MAX_ROOM_CHOICES visible_rooms = rooms[start : start + MAX_ROOM_CHOICES] - heading = "Group Chats" if page_count == 1 else f"Group Chats — page {page} of {page_count}" + heading = ( + "Group Chats" + if page_count == 1 + else f"Group Chats — page {page} of {page_count}" + ) lines = [f"👥 **{heading}**"] for room in visible_rooms: name = _clean_line(room.get("name") or room.get("room_id"), limit=72) @@ -1343,18 +1335,16 @@ def format_room_list( lines.append(f"More: `{rooms_command} list {page + 1}`") elif page > 1: lines.append(f"Previous: `{rooms_command} list {page - 1}`") - lines.extend( - [ - "", - "────────", - "🧭 **Controls**", - f"Check: `{rooms_command} `", - f"Send: `{rooms_command} send `", - f"Bots: `{rooms_command} bots`", - f"Retry: `{rooms_command} retry`", - f"Stop: `{rooms_command} stop`", - ] - ) + lines.extend([ + "", + "────────", + "🧭 **Controls**", + f"Check: `{rooms_command} `", + f"Send: `{rooms_command} send `", + f"Bots: `{rooms_command} bots`", + f"Retry: `{rooms_command} retry`", + f"Stop: `{rooms_command} stop`", + ]) return "\n".join(lines) @@ -1407,9 +1397,7 @@ def format_room_detail( remote_room = summary["room"] raw_remote_members = remote_room.get("members") members = ( - list(raw_remote_members) - if isinstance(raw_remote_members, list) - else [] + list(raw_remote_members) if isinstance(raw_remote_members, list) else [] ) room = { **room, @@ -1485,11 +1473,17 @@ def format_room_detail( lines.extend(["", "🕘 **Recent activity**"]) for event in visible: if desktop_mode: - source = event.get("from") if isinstance(event.get("from"), Mapping) else {} + source = ( + event.get("from") if isinstance(event.get("from"), Mapping) else {} + ) label = _clean_line(source.get("name") or "You", limit=48) text = event.get("text") else: - payload = event.get("payload") if isinstance(event.get("payload"), Mapping) else {} + payload = ( + event.get("payload") + if isinstance(event.get("payload"), Mapping) + else {} + ) label = _event_label(event, member_names) text = payload.get("text") lines.append( @@ -1514,10 +1508,7 @@ def format_room_detail( room, remote_status=action_status, ) - if ( - desktop_mode - and failed_commands > 0 - ): + if desktop_mode and failed_commands > 0: lines.append( ( "The latest command could not be applied." @@ -1527,9 +1518,7 @@ def format_room_detail( + " Retry here or open this Group Chat in Hermes Desktop." ) lines.extend(["", "────────", "🧭 **Controls**"]) - lines.append( - f"Send: `{room_command} {room_reference(room)} send `" - ) + lines.append(f"Send: `{room_command} {room_reference(room)} send `") lines.append(f"Bots: `{room_command} {room_reference(room)} bots`") if show_retry: lines.append(f"Retry: `{room_command} {room_reference(room)} retry`") @@ -1615,15 +1604,9 @@ def _raw_transport_id(event: Any) -> Any: ) ) if raw is not None and not isinstance(raw, Mapping): - candidates.extend( - getattr(raw, key, None) for key in ("id", "interaction_id") - ) + candidates.extend(getattr(raw, key, None) for key in ("id", "interaction_id")) return next( - ( - value - for value in candidates - if value is not None and str(value).strip() - ), + (value for value in candidates if value is not None and str(value).strip()), None, ) @@ -1828,7 +1811,11 @@ def stop_room(service: Any, room: Mapping[str, Any], event: Any) -> str: authority_hash=_desktop_authority_hash(room), action="stop", payload={ - **({"target_command_id": target_command_id} if target_command_id else {}), + **( + {"target_command_id": target_command_id} + if target_command_id + else {} + ), **({"target_thread_id": target_thread_id} if target_thread_id else {}), }, ) @@ -1878,9 +1865,7 @@ def retry_room(service: Any, room: Mapping[str, Any], event: Any) -> str: return f"Retry checked for {name} ({processed} {suffix})." summary = result.get("summary") retried = ( - int(summary.get("retried") or 0) - if isinstance(summary, Mapping) - else 0 + int(summary.get("retried") or 0) if isinstance(summary, Mapping) else 0 ) suffix = "task" if retried == 1 else "tasks" return f"Retry queued for {name} ({retried} {suffix})." diff --git a/gateway/hosted_room_messaging_approvals.py b/gateway/hosted_room_messaging_approvals.py index dfab543ad726f..723a928bd21c3 100644 --- a/gateway/hosted_room_messaging_approvals.py +++ b/gateway/hosted_room_messaging_approvals.py @@ -57,23 +57,21 @@ def _display_text(value: Any, *, limit: int) -> str: """Keep untrusted labels readable without letting them impersonate controls.""" return _text(value)[:limit].translate( - str.maketrans( - { - "@": "@", - "\\": "\", - "`": "`", - "*": "*", - "_": "_", - "{": "{", - "}": "}", - "[": "[", - "]": "]", - "#": "#", - "|": "|", - ">": ">", - "~": "~", - } - ) + str.maketrans({ + "@": "@", + "\\": "\", + "`": "`", + "*": "*", + "_": "_", + "{": "{", + "}": "}", + "[": "[", + "]": "]", + "#": "#", + "|": "|", + ">": ">", + "~": "~", + }) ) @@ -185,7 +183,9 @@ def _require_observer_lease( return lease_generation = int(observation.get("observer_lease_generation") or 0) if lease_generation < 1: - raise MessagingApprovalObservationStale("approval observer lease is unavailable") + raise MessagingApprovalObservationStale( + "approval observer lease is unavailable" + ) try: room = conn.execute( """SELECT authority_gateway_id, authority_epoch, disbanded_at @@ -209,8 +209,7 @@ def _require_observer_lease( or room["disbanded_at"] is not None or str(room["authority_gateway_id"]) != str(observation.get("authority_gateway_id") or "") - or int(room["authority_epoch"]) - != int(observation.get("authority_epoch") or 0) + or int(room["authority_epoch"]) != int(observation.get("authority_epoch") or 0) or row is None or str(row["gateway_id"]) != str(observation.get("authority_gateway_id") or "") or int(row["authority_epoch"]) != int(observation.get("authority_epoch") or 0) @@ -232,9 +231,7 @@ def normalize_pending_approval( approval = action.get("approval") if not isinstance(approval, Mapping): raise MessagingApprovalError("pending approval details are unavailable") - choices = { - str(choice or "").casefold() for choice in approval.get("choices") or () - } + choices = {str(choice or "").casefold() for choice in approval.get("choices") or ()} if not {"once", "deny"} <= choices: raise MessagingApprovalError("pending approval choices are unsafe") generation = int(action.get("execution_generation") or 0) @@ -269,9 +266,7 @@ def normalize_pending_approval( action.get("observer_generation") or "legacy", label="observer_generation", ), - "observer_lease_generation": int( - action.get("observer_lease_generation") or 0 - ), + "observer_lease_generation": int(action.get("observer_lease_generation") or 0), "approval": { "description": _text(approval.get("description")), "command": _text(approval.get("command")), @@ -356,9 +351,7 @@ def clear_pending_approval( room = _identifier(room_id, label="room_id") member = _identifier(member_id, label="member_id") request = ( - _identifier(request_id, label="request_id") - if request_id is not None - else "" + _identifier(request_id, label="request_id") if request_id is not None else "" ) authority_gateway = ( _identifier(authority_gateway_id, label="authority_gateway_id") @@ -845,7 +838,9 @@ def select_pending_approval( if approval_reference(action).casefold() == raw.casefold() ] if len(matches) != 1: - raise MessagingApprovalError("Choose the approval code shown in the Group Chat.") + raise MessagingApprovalError( + "Choose the approval code shown in the Group Chat." + ) return matches[0] @@ -971,9 +966,7 @@ def format_pending_approvals( description, command = _approval_display_parts(approval) detail = description or command or "Command" reference = approval_reference(action) - lines.append( - f"{index}. **{label}** · {detail} · `{reference}`" - ) + lines.append(f"{index}. **{label}** · {detail} · `{reference}`") if command and command != detail: lines.append(f" Command: {command}") lines.extend([ @@ -1012,10 +1005,7 @@ def approval_picker_choices( room, str(action["member_id"]), ) - coordinates = "\0".join( - str(action[field]) - for field in _APPROVAL_SCOPE_FIELDS - ) + coordinates = "\0".join(str(action[field]) for field in _APPROVAL_SCOPE_FIELDS) once_token = hashlib.sha256( f"{index}\0once\0{coordinates}".encode() ).hexdigest()[:20] diff --git a/gateway/platforms/api_server_room_controls.py b/gateway/platforms/api_server_room_controls.py index cd77275f77a80..0443a91689c61 100644 --- a/gateway/platforms/api_server_room_controls.py +++ b/gateway/platforms/api_server_room_controls.py @@ -106,7 +106,9 @@ def _backend() -> MessagingRoomBackend: service = get_hosted_room_service() return MessagingRoomBackend( - db_path=(service.db_path if service is not None else hosted_rooms.default_db_path()), + db_path=( + service.db_path if service is not None else hosted_rooms.default_db_path() + ), service=service, ) @@ -130,19 +132,17 @@ def _visible_events(room_id: str) -> list[dict[str, Any]]: actor = raw_actor if isinstance(raw_actor, Mapping) else {} raw_payload = event.get("payload") payload = raw_payload if isinstance(raw_payload, Mapping) else {} - visible.append( - { - "kind": event["kind"], - "actor": { - "id": str(actor.get("id") or "")[:256], - "display_name": str(actor.get("display_name") or "")[:128], - }, - "payload": { - "member_id": str(payload.get("member_id") or "")[:256], - "text": str(payload.get("text") or "")[:MAX_CONTROL_TEXT_CHARS], - }, - } - ) + visible.append({ + "kind": event["kind"], + "actor": { + "id": str(actor.get("id") or "")[:256], + "display_name": str(actor.get("display_name") or "")[:128], + }, + "payload": { + "member_id": str(payload.get("member_id") or "")[:256], + "text": str(payload.get("text") or "")[:MAX_CONTROL_TEXT_CHARS], + }, + }) return visible[-MAX_CONTROL_EVENTS:] @@ -155,13 +155,11 @@ def _summary(room: Mapping[str, Any], backend: MessagingRoomBackend) -> dict[str for raw_member in list(room.get("members") or []): if not isinstance(raw_member, Mapping): continue - members.append( - { - "member_id": str(raw_member.get("member_id") or "")[:256], - "handle": str(raw_member.get("handle") or "")[:128], - "display_name": str(raw_member.get("display_name") or "")[:128], - } - ) + members.append({ + "member_id": str(raw_member.get("member_id") or "")[:256], + "handle": str(raw_member.get("handle") or "")[:128], + "display_name": str(raw_member.get("display_name") or "")[:128], + }) return { "room": { "room_id": room_id, diff --git a/gateway/run.py b/gateway/run.py index c924b557e900a..9e679f73056b3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17816,33 +17816,6 @@ async def _resolve_async_delegation_session( "moa": "Agent is running — wait or /stop first, then run /moa.", } - def _gateway_plain_command_handlers(self): - """Return ordinary slash handlers shared by idle and busy dispatch.""" - return { - "status": self._handle_status_command, - "context": self._handle_context_command, - "restart": self._handle_restart_command, - "approve": self._handle_approve_command, - "deny": self._handle_deny_command, - "pause": self._handle_pause_command, - "agents": self._handle_agents_command, - "bg": self._handle_background_command, - "btw": self._handle_btw_command, - "kanban": self._handle_kanban_command, - "group": self._handle_rooms_command, - "subgoal": self._handle_subgoal_command, - "heartbeat": self._handle_heartbeat_command, - "busy": self._handle_busy_command, - "yolo": self._handle_yolo_command, - "verbose": self._handle_verbose_command, - "footer": self._handle_footer_command, - "help": self._handle_help_command, - "commands": self._handle_commands_command, - "profile": self._handle_profile_command, - "update": self._handle_update_command, - "version": self._handle_version_command, - } - async def _dispatch_busy_slash_command( self, event: MessageEvent, cmd_def, quick_key: str, source, ): @@ -19198,12 +19171,6 @@ async def _do_reset(): if canonical == "personality": return await self._handle_personality_command(event) - if canonical == "kanban": - return await self._handle_kanban_command(event) - - if canonical == "group": - return await self._handle_rooms_command(event) - if canonical == "suggestions": return await self._handle_suggestions_command(event) diff --git a/gateway/session.py b/gateway/session.py index 620aaf27d1d9d..5901405636961 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -90,6 +90,7 @@ def _hash_chat_id(value: str) -> str: SessionResetPolicy, # noqa: F401 — re-exported via gateway/__init__.py HomeChannel, ) +from .session_source import SessionSource from .whatsapp_identity import ( canonical_whatsapp_identifier, normalize_whatsapp_identifier, # noqa: F401 - re-exported for gateway.session callers @@ -145,180 +146,6 @@ def _is_session_key_unsafe(value: object) -> bool: return len(s) >= 2 and s[0].isalpha() and s[1] == ":" -@dataclass -class SessionSource: - """ - Describes where a message originated from. - - This information is used to: - 1. Route responses back to the right place - 2. Inject context into the system prompt - 3. Track origin for cron job delivery - """ - platform: Platform - chat_id: str - chat_name: Optional[str] = None - chat_type: str = "dm" # "dm", "group", "channel", "thread" - user_id: Optional[str] = None - user_name: Optional[str] = None - thread_id: Optional[str] = None # For forum topics, Discord threads, etc. - chat_topic: Optional[str] = None # Channel topic/description (Discord, Slack) - user_id_alt: Optional[str] = None # Platform-specific stable alt ID (Signal UUID, Feishu union_id) - chat_id_alt: Optional[str] = None # Signal group internal ID - is_bot: bool = False # True when the message author is a bot/webhook (Discord) - # Platform-neutral SCOPE discriminator (Discord guild / Slack workspace / - # Matrix server). Drives server/workspace isolation + the relay δ/ε/ζ gate. - # Wire migration (D-Q2.5): `scope_id` is the canonical name; `guild_id` is a - # deprecated legacy alias kept during the cross-repo dual-read/dual-write - # overlap. Both are written by to_dict and read by from_dict (scope_id wins); - # the `guild_id` alias is dropped in a follow-up once both repos deploy. - scope_id: Optional[str] = None - guild_id: Optional[str] = None # @deprecated legacy alias for scope_id (D-Q2.5) - parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread - message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react) - role_authorized: bool = False # True when adapter granted access via role (not user ID) - # Profile this inbound message is routed to in a multiplexing gateway - # (from the /p// URL prefix or per-credential adapter ownership). - # None => the gateway's active/default profile. Drives both session-key - # namespacing and the per-turn config/credential scope. - profile: Optional[str] = None - # Transport-local fail-closed signal for an explicit profile route whose - # target is not served. Excluded from repr/equality and wire serialization. - profile_route_rejected: bool = field(default=False, repr=False, compare=False) - # Transport-local trust facts; never deserialize these from stored/wire data. - is_one_to_one: Optional[bool] = field(default=None, repr=False, compare=False) - message_is_edit: bool = field(default=False, repr=False, compare=False) - message_had_attachments: bool = field(default=False, repr=False, compare=False) - - # Discord auto-thread metadata. Newly auto-created Discord threads start - # with a fast placeholder title from the raw message, then the gateway can - # rename them after the first agent turn using the generated session title. - # Keep this explicit so pre-existing or human-renamed threads are not - # mistaken for safe rename targets. - auto_thread_created: bool = False - auto_thread_initial_name: Optional[str] = None - - # Discord auto-thread session-continuity signal. Set by the connector on an - # inbound CHANNEL message (no thread_id yet) that its auto-thread policy WILL - # deliver into a newly-created thread. A Discord thread created from a message - # reuses that message's id as the thread id, so the connector knows the id - # before the thread exists. The gateway keys the session on this so a - # channel message and its thread follow-ups share ONE session: the channel - # message INITIATES it (keyed on the prospective thread id), and later - # messages arriving in that thread (real thread_id == this value) CONTINUE - # it. Without this, every channel message collapses into one parent-channel - # session and only the first auto-thread ever gets an auto-title/rename. - prospective_thread_id: Optional[str] = None - - # Internal, wire-INVISIBLE trust signal: True when this event was delivered - # to the gateway over the per-instance-authenticated relay WebSocket (the - # Team Gateway connector). The connector authenticates the gateway's socket - # with a per-instance secret and resolves owner-only author bindings BEFORE - # delivering, so a relay-delivered event is already authorized as this - # instance's bound user. ``platform`` carries the UNDERLYING platform - # (e.g. ``discord``) for session-keying/egress, NOT ``relay`` — so authz - # must key the upstream-trust decision off THIS flag, not off ``platform``. - # Set locally by the relay transport (``ws_transport._event_from_wire``); - # deliberately excluded from ``to_dict``/``from_dict`` so a peer can never - # forge it across the wire or have it restored from persistence. - delivered_via_upstream_relay: bool = False - - def __post_init__(self) -> None: - # D-Q2.5 dual-field reconciliation: `scope_id` is canonical, `guild_id` - # is the deprecated alias. Mirror whichever was provided onto the other - # (scope_id wins on conflict) so internal readers of EITHER field see the - # same value during the cross-repo wire migration overlap. - if self.scope_id is None and self.guild_id is not None: - self.scope_id = self.guild_id - elif self.scope_id is not None: - self.guild_id = self.scope_id - - @property - def description(self) -> str: - """Human-readable description of the source.""" - if self.platform == Platform.LOCAL: - return "CLI terminal" - - parts = [] - if self.chat_type == "dm": - parts.append(f"DM with {self.user_name or self.user_id or 'user'}") - elif self.chat_type == "group": - parts.append(f"group: {self.chat_name or self.chat_id}") - elif self.chat_type == "channel": - parts.append(f"channel: {self.chat_name or self.chat_id}") - else: - parts.append(self.chat_name or self.chat_id) - - if self.thread_id: - parts.append(f"thread: {self.thread_id}") - - return ", ".join(parts) - - def to_dict(self) -> Dict[str, Any]: - d = { - "platform": self.platform.value, - "chat_id": self.chat_id, - "chat_name": self.chat_name, - "chat_type": self.chat_type, - "user_id": self.user_id, - "user_name": self.user_name, - "thread_id": self.thread_id, - "chat_topic": self.chat_topic, - "is_bot": self.is_bot, - } - if self.user_id_alt: - d["user_id_alt"] = self.user_id_alt - if self.chat_id_alt: - d["chat_id_alt"] = self.chat_id_alt - # D-Q2.5 dual-write: emit BOTH the canonical `scope_id` and the - # deprecated `guild_id` alias (mirrored in __post_init__) so a connector - # on either side of the migration resolves the scope. Drop `guild_id` - # in the follow-up once both repos are on `scope_id`. - scope = self.scope_id if self.scope_id is not None else self.guild_id - if scope: - d["scope_id"] = scope - d["guild_id"] = scope - if self.parent_chat_id: - d["parent_chat_id"] = self.parent_chat_id - if self.message_id: - d["message_id"] = self.message_id - if self.profile: - d["profile"] = self.profile - if self.auto_thread_created: - d["auto_thread_created"] = True - if self.auto_thread_initial_name: - d["auto_thread_initial_name"] = self.auto_thread_initial_name - if self.prospective_thread_id: - d["prospective_thread_id"] = self.prospective_thread_id - return d - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": - return cls( - platform=Platform(data["platform"]), - chat_id=str(data["chat_id"]), - chat_name=data.get("chat_name"), - chat_type=data.get("chat_type", "dm"), - user_id=data.get("user_id"), - user_name=data.get("user_name"), - thread_id=data.get("thread_id"), - chat_topic=data.get("chat_topic"), - user_id_alt=data.get("user_id_alt"), - chat_id_alt=data.get("chat_id_alt"), - # D-Q2.5 dual-read: prefer the canonical `scope_id`, fall back to the - # deprecated `guild_id` alias (a peer not yet migrated still sends it). - scope_id=data.get("scope_id", data.get("guild_id")), - parent_chat_id=data.get("parent_chat_id"), - message_id=data.get("message_id"), - is_bot=bool(data.get("is_bot", False)), - profile=data.get("profile"), - auto_thread_created=bool(data.get("auto_thread_created", False)), - auto_thread_initial_name=data.get("auto_thread_initial_name"), - prospective_thread_id=data.get("prospective_thread_id"), - ) - - - @dataclass class SessionContext: """ diff --git a/gateway/session_source.py b/gateway/session_source.py new file mode 100644 index 0000000000000..fcf285faab7ee --- /dev/null +++ b/gateway/session_source.py @@ -0,0 +1,184 @@ +"""Message-origin identity shared by gateway routing and persistence.""" + +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + +from .config import Platform + + +@dataclass +class SessionSource: + """ + Describes where a message originated from. + + This information is used to: + 1. Route responses back to the right place + 2. Inject context into the system prompt + 3. Track origin for cron job delivery + """ + + platform: Platform + chat_id: str + chat_name: Optional[str] = None + chat_type: str = "dm" # "dm", "group", "channel", "thread" + user_id: Optional[str] = None + user_name: Optional[str] = None + thread_id: Optional[str] = None # For forum topics, Discord threads, etc. + chat_topic: Optional[str] = None # Channel topic/description (Discord, Slack) + user_id_alt: Optional[str] = ( + None # Platform-specific stable alt ID (Signal UUID, Feishu union_id) + ) + chat_id_alt: Optional[str] = None # Signal group internal ID + is_bot: bool = False # True when the message author is a bot/webhook (Discord) + # Platform-neutral SCOPE discriminator (Discord guild / Slack workspace / + # Matrix server). Drives server/workspace isolation + the relay delta/epsilon/zeta gate. + # Wire migration (D-Q2.5): `scope_id` is the canonical name; `guild_id` is a + # deprecated legacy alias kept during the cross-repo dual-read/dual-write + # overlap. Both are written by to_dict and read by from_dict (scope_id wins); + # the `guild_id` alias is dropped in a follow-up once both repos deploy. + scope_id: Optional[str] = None + guild_id: Optional[str] = None # @deprecated legacy alias for scope_id (D-Q2.5) + parent_chat_id: Optional[str] = ( + None # Parent channel when chat_id refers to a thread + ) + message_id: Optional[str] = ( + None # ID of the triggering message (for pin/reply/react) + ) + role_authorized: bool = ( + False # True when adapter granted access via role (not user ID) + ) + # Profile this inbound message is routed to in a multiplexing gateway + # (from the /p// URL prefix or per-credential adapter ownership). + # None => the gateway's active/default profile. Drives both session-key + # namespacing and the per-turn config/credential scope. + profile: Optional[str] = None + # Transport-local fail-closed signal for an explicit profile route whose + # target is not served. Excluded from repr/equality and wire serialization. + profile_route_rejected: bool = field(default=False, repr=False, compare=False) + # Transport-local trust facts; never deserialize these from stored/wire data. + is_one_to_one: Optional[bool] = field(default=None, repr=False, compare=False) + message_is_edit: bool = field(default=False, repr=False, compare=False) + message_had_attachments: bool = field(default=False, repr=False, compare=False) + + # Discord auto-thread metadata. Newly auto-created Discord threads start + # with a fast placeholder title from the raw message, then the gateway can + # rename them after the first agent turn using the generated session title. + # Keep this explicit so pre-existing or human-renamed threads are not + # mistaken for safe rename targets. + auto_thread_created: bool = False + auto_thread_initial_name: Optional[str] = None + + # Discord auto-thread session-continuity signal. Set by the connector on an + # inbound CHANNEL message (no thread_id yet) that its auto-thread policy WILL + # deliver into a newly-created thread. A Discord thread created from a message + # reuses that message's id as the thread id, so the connector knows the id + # before the thread exists. The gateway keys the session on this so a + # channel message and its thread follow-ups share ONE session: the channel + # message INITIATES it (keyed on the prospective thread id), and later + # messages arriving in that thread (real thread_id == this value) CONTINUE + # it. Without this, every channel message collapses into one parent-channel + # session and only the first auto-thread ever gets an auto-title/rename. + prospective_thread_id: Optional[str] = None + + # Internal, wire-INVISIBLE trust signal: True when this event was delivered + # to the gateway over the per-instance-authenticated relay WebSocket (the + # Team Gateway connector). The connector authenticates the gateway's socket + # with a per-instance secret and resolves owner-only author bindings BEFORE + # delivering, so a relay-delivered event is already authorized as this + # instance's bound user. `platform` carries the UNDERLYING platform (for + # example, `discord`) for session-keying/egress, not `relay`, so authz must + # key the upstream-trust decision off this flag rather than `platform`. + # Set locally by the relay transport; deliberately excluded from + # `to_dict`/`from_dict` so a peer can never forge it across the wire or have + # it restored from persistence. + delivered_via_upstream_relay: bool = False + + def __post_init__(self) -> None: + # D-Q2.5 dual-field reconciliation: `scope_id` is canonical, `guild_id` + # is the deprecated alias. Mirror whichever was provided onto the other + # (scope_id wins on conflict) so internal readers of either field see the + # same value during the cross-repo wire migration overlap. + if self.scope_id is None and self.guild_id is not None: + self.scope_id = self.guild_id + elif self.scope_id is not None: + self.guild_id = self.scope_id + + @property + def description(self) -> str: + """Human-readable description of the source.""" + if self.platform == Platform.LOCAL: + return "CLI terminal" + + parts = [] + if self.chat_type == "dm": + parts.append(f"DM with {self.user_name or self.user_id or 'user'}") + elif self.chat_type == "group": + parts.append(f"group: {self.chat_name or self.chat_id}") + elif self.chat_type == "channel": + parts.append(f"channel: {self.chat_name or self.chat_id}") + else: + parts.append(self.chat_name or self.chat_id) + + if self.thread_id: + parts.append(f"thread: {self.thread_id}") + + return ", ".join(parts) + + def to_dict(self) -> Dict[str, Any]: + d = { + "platform": self.platform.value, + "chat_id": self.chat_id, + "chat_name": self.chat_name, + "chat_type": self.chat_type, + "user_id": self.user_id, + "user_name": self.user_name, + "thread_id": self.thread_id, + "chat_topic": self.chat_topic, + "is_bot": self.is_bot, + } + if self.user_id_alt: + d["user_id_alt"] = self.user_id_alt + if self.chat_id_alt: + d["chat_id_alt"] = self.chat_id_alt + # D-Q2.5 dual-write: emit both the canonical `scope_id` and deprecated + # `guild_id` alias so a connector on either migration side resolves it. + scope = self.scope_id if self.scope_id is not None else self.guild_id + if scope: + d["scope_id"] = scope + d["guild_id"] = scope + if self.parent_chat_id: + d["parent_chat_id"] = self.parent_chat_id + if self.message_id: + d["message_id"] = self.message_id + if self.profile: + d["profile"] = self.profile + if self.auto_thread_created: + d["auto_thread_created"] = True + if self.auto_thread_initial_name: + d["auto_thread_initial_name"] = self.auto_thread_initial_name + if self.prospective_thread_id: + d["prospective_thread_id"] = self.prospective_thread_id + return d + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": + return cls( + platform=Platform(data["platform"]), + chat_id=str(data["chat_id"]), + chat_name=data.get("chat_name"), + chat_type=data.get("chat_type", "dm"), + user_id=data.get("user_id"), + user_name=data.get("user_name"), + thread_id=data.get("thread_id"), + chat_topic=data.get("chat_topic"), + user_id_alt=data.get("user_id_alt"), + chat_id_alt=data.get("chat_id_alt"), + scope_id=data.get("scope_id", data.get("guild_id")), + parent_chat_id=data.get("parent_chat_id"), + message_id=data.get("message_id"), + is_bot=bool(data.get("is_bot", False)), + profile=data.get("profile"), + auto_thread_created=bool(data.get("auto_thread_created", False)), + auto_thread_initial_name=data.get("auto_thread_initial_name"), + prospective_thread_id=data.get("prospective_thread_id"), + ) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 86f0618c674d8..9277945e3c748 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -34,6 +34,7 @@ from agent.turn_context import extract_api_content_sidecar from gateway.config import HomeChannel, Platform, PlatformConfig, persist_home_channel from gateway.group_chat_slash import GroupChatSlashCommandsMixin +from gateway.slash_dispatch import GatewaySlashDispatchMixin from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType from gateway.session import ( AsyncSessionStore, @@ -123,7 +124,10 @@ def _home_thread_from_source(source) -> Optional[str]: return str(thread_id) -class GatewaySlashCommandsMixin(GroupChatSlashCommandsMixin): +class GatewaySlashCommandsMixin( + GroupChatSlashCommandsMixin, + GatewaySlashDispatchMixin, +): """In-session slash-command handlers for GatewayRunner.""" async_session_store: AsyncSessionStore @@ -3863,47 +3867,6 @@ def _reasoning_picker_choices(self, current_effort: str) -> list: ) return choices - async def _try_send_choice_picker( - self, - event: MessageEvent, - session_key: str, - title: str, - choices: list, - on_choice_selected, - ) -> bool: - """Send an interactive choice picker when the platform supports it. - - Mirrors the `/model` picker gate: the capability is detected on the - adapter *type* (``send_choice_picker``), and a failed send falls back - to the text path (returns False) instead of erroring the command. - """ - adapter = getattr(self, "_adapter_for_source")(event.source) - has_picker = ( - adapter is not None - and getattr(type(adapter), "send_choice_picker", None) is not None - ) - if not has_picker: - return False - try: - metadata = dict(self._thread_metadata_for_source( - event.source, self._reply_anchor_for_event(event) - ) or {}) - requester_user_id = getattr(event.source, "user_id", None) - if requester_user_id is not None: - metadata["requester_user_id"] = str(requester_user_id) - result = await adapter.send_choice_picker( - chat_id=event.source.chat_id, - title=title, - choices=choices, - session_key=session_key, - on_choice_selected=on_choice_selected, - metadata=metadata, - ) - return bool(getattr(result, "success", False)) - except Exception as e: - logger.warning("send_choice_picker failed, falling back to text: %s", e) - return False - async def _handle_reasoning_command(self, event: MessageEvent) -> Optional[str]: """Handle /reasoning command — manage reasoning effort and display toggle. diff --git a/gateway/slash_dispatch.py b/gateway/slash_dispatch.py new file mode 100644 index 0000000000000..c30f9c8bbdc62 --- /dev/null +++ b/gateway/slash_dispatch.py @@ -0,0 +1,78 @@ +"""Small slash-dispatch primitives shared by idle and busy gateway paths.""" + +import logging + +from gateway.platforms.base import MessageEvent + + +logger = logging.getLogger("gateway.run") + + +class GatewaySlashDispatchMixin: + """Shared handler lookup and native finite-choice delivery.""" + + def _gateway_plain_command_handlers(self): + """Return ordinary slash handlers shared by idle and busy dispatch.""" + return { + "status": self._handle_status_command, + "context": self._handle_context_command, + "restart": self._handle_restart_command, + "approve": self._handle_approve_command, + "deny": self._handle_deny_command, + "pause": self._handle_pause_command, + "agents": self._handle_agents_command, + "bg": self._handle_background_command, + "btw": self._handle_btw_command, + "kanban": self._handle_kanban_command, + "group": self._handle_rooms_command, + "subgoal": self._handle_subgoal_command, + "heartbeat": self._handle_heartbeat_command, + "busy": self._handle_busy_command, + "yolo": self._handle_yolo_command, + "verbose": self._handle_verbose_command, + "footer": self._handle_footer_command, + "help": self._handle_help_command, + "commands": self._handle_commands_command, + "profile": self._handle_profile_command, + "update": self._handle_update_command, + "version": self._handle_version_command, + } + + async def _try_send_choice_picker( + self, + event: MessageEvent, + session_key: str, + title: str, + choices: list, + on_choice_selected, + ) -> bool: + """Send a native picker when supported, otherwise use text fallback.""" + adapter = self._adapter_for_source(event.source) + has_picker = ( + adapter is not None + and getattr(type(adapter), "send_choice_picker", None) is not None + ) + if not has_picker: + return False + try: + metadata = dict( + self._thread_metadata_for_source( + event.source, self._reply_anchor_for_event(event) + ) + or {} + ) + requester_user_id = getattr(event.source, "user_id", None) + if requester_user_id is not None: + metadata["requester_user_id"] = str(requester_user_id) + result = await adapter.send_choice_picker( + chat_id=event.source.chat_id, + title=title, + choices=choices, + session_key=session_key, + on_choice_selected=on_choice_selected, + metadata=metadata, + ) + return bool(getattr(result, "success", False)) + except Exception as exc: + logger.warning("send_choice_picker failed, falling back to text: %s", exc) + return False diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 6bfc51dbc4603..862af92aa7039 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -22,6 +22,13 @@ from utils import is_truthy_value from hermes_constants import INDICATOR_STYLES +from hermes_cli.slack_command_policy import ( + _SLACK_MAX_SLASH_COMMANDS, + _SLACK_PRIORITY_ALIASES, + _SLACK_RESERVED_COMMANDS, + _SLACK_VIA_HERMES_ONLY, + _sanitize_slack_name, +) # mtime-keyed memo of the /personality completion source. load_cli_config() # does a full YAML parse + deep merge of the built-in defaults on every call, @@ -1409,94 +1416,6 @@ def discord_skill_commands_by_category( # Slack native slash commands # --------------------------------------------------------------------------- -# Slack slash command name constraints: lowercase a-z, 0-9, hyphens, -# underscores. Max 32 chars. Slack app manifest accepts up to 50 slash -# commands per app. -_SLACK_MAX_SLASH_COMMANDS = 50 -_SLACK_NAME_LIMIT = 32 -_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]") -_SLACK_RESERVED_COMMANDS = frozenset({ - # Built-in Slack slash commands that cannot be registered by apps. - # https://slack.com/help/articles/201259356-Use-built-in-slash-commands - "me", "status", "away", "dnd", "shrug", "remind", "msg", "feed", - "who", "collapse", "expand", "leave", "join", "open", "search", - "topic", "mute", "pro", "shortcuts", -}) - -# High-value aliases that must survive Slack's 50-slash cap even when the -# registry fills up. Without this, adding a new canonical command silently -# clamps off low-priority aliases (they're added in the second pass), so a -# long-standing native slash like /btw could disappear just because an -# unrelated command landed. These claim their slots right after /hermes, -# ahead of both canonical names and the rest of the aliases. Anything not -# listed here still degrades gracefully (reachable via /hermes ). -# Keep this list TIGHT: every pinned alias takes a slot a canonical command -# would otherwise get, and the Telegram-parity test fails when a canonical -# gets clamped ("reset" was unpinned for exactly that — /new keeps its -# native slot, the alias spelling stays reachable via /hermes reset). -# (Currently empty: /bg and /btw were promoted from aliases of /background -# to canonical commands, so they win first-pass slots on their own.) -_SLACK_PRIORITY_ALIASES: tuple[str, ...] = () - -# Canonical commands intentionally NOT given a native Slack slash slot. Slack -# caps apps at 50 slash commands and the registry is at that ceiling; rather -# than let the clamp silently drop whichever command sorts last (and break -# Telegram parity), we explicitly route a few low-frequency commands through -# ``/hermes `` on Slack only. They remain native on every other -# surface (CLI, TUI, Telegram, Discord). Keep this list TIGHT and intentional — -# the telegram-parity test reads it so an entry here is a deliberate -# "Slack-via-/hermes" decision, not a silent clamp. -# - topup: the billing/balance surface; reached via /hermes topup on Slack. -# (the rehaul folded the old /credits + /billing surfaces into /topup.) -# - moa: high-cost slash mode, available through /hermes moa to avoid -# displacing existing native Slack slash commands at the 50-command cap. -# - debug: the log/report upload surface; reached via /hermes debug on Slack. -# - egress: Docker-only proxy status; reachable as /hermes egress on Slack. -# - init: repo-scan AGENTS.md bootstrap — a cwd-centric dev command that is -# rare from Slack; reachable as /hermes init. Without this entry, adding -# /init clamps /version off the native list and breaks Telegram parity. -# - version: low-frequency info command; reachable as /hermes version on -# Slack. Demoted when /context claimed a native slot (context is a -# recurring inspection surface; version is a one-off lookup); the demotion -# also absorbs the native slot /approvals now consumes at the 50-cap. -# - diff: git working-tree diff; reached via /hermes diff on Slack so it -# doesn't displace an existing native slash at the 50-command cap. -# - update: low-frequency self-update maintenance command; reached via -# /hermes update on Slack. Demoted to free the native slot /approvals now -# claims — without this entry /approvals tips the registry past the 50-cap -# and silently clamps /update off, breaking Telegram parity. -# - heartbeat: session heartbeat management; reached via /hermes heartbeat -# on Slack. Added at the 50-cap — a native slot would clamp /insights. -# - refine: on-demand memory/skill review; reached via /hermes refine on -# Slack. Added at the 50-cap — a native slot would clamp an existing -# native slash. -# - pause: global emergency stop; reached via /hermes pause [off] on -# Slack. Added at the 50-cap — a native slot would clamp /platform. -# - whoami: one-off identity lookup; reached via /hermes whoami on Slack. -# Demoted when /loop claimed a native slot (loop is a recurring -# interactive surface; whoami is a rare debug lookup) — without this -# entry /loop tips the registry past the 50-cap and silently clamps -# /platform, breaking Telegram parity. -# - platform: informational platform/environment lookup; reached via -# /hermes platform on Slack. Demoted when /save became gateway-available -# (session export is an interactive surface; platform is a rare -# informational lookup) — without this entry /save tips the registry -# past the 50-cap and silently clamps /platform, breaking parity. -_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "review", "pause", "whoami", "platform", "insights", "group"}) - - -def _sanitize_slack_name(raw: str) -> str: - """Convert a command name to a valid Slack slash command name. - - Slack allows lowercase a-z, digits, hyphens, and underscores. Max 32 - chars. Uppercase is lowercased; invalid chars are stripped. - """ - name = raw.lower() - name = _SLACK_INVALID_CHARS.sub("", name) - name = name.strip("-_") - return name[:_SLACK_NAME_LIMIT] - - def slack_native_slashes() -> list[tuple[str, str, str]]: """Return (slash_name, description, usage_hint) triples for Slack. diff --git a/hermes_cli/slack_command_policy.py b/hermes_cli/slack_command_policy.py new file mode 100644 index 0000000000000..ca64b991447a2 --- /dev/null +++ b/hermes_cli/slack_command_policy.py @@ -0,0 +1,64 @@ +"""Slack-native slash naming and finite manifest policy.""" + +import re + + +_SLACK_MAX_SLASH_COMMANDS = 50 +_SLACK_NAME_LIMIT = 32 +_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]") +_SLACK_RESERVED_COMMANDS = frozenset({ + # Built-in Slack slash commands that cannot be registered by apps. + "me", + "status", + "away", + "dnd", + "shrug", + "remind", + "msg", + "feed", + "who", + "collapse", + "expand", + "leave", + "join", + "open", + "search", + "topic", + "mute", + "pro", + "shortcuts", +}) + +# Pinned aliases claim slots before canonical names. This stays deliberately +# empty now that /bg and /btw are canonical commands. +_SLACK_PRIORITY_ALIASES: tuple[str, ...] = () + +# Slack caps an app at 50 slash commands. These low-frequency commands stay +# reachable through `/hermes ` instead of silently displacing another +# native command when the shared registry grows. +_SLACK_VIA_HERMES_ONLY = frozenset({ + "topup", + "moa", + "debug", + "egress", + "init", + "version", + "diff", + "update", + "heartbeat", + "refine", + "review", + "pause", + "whoami", + "platform", + "insights", + "group", +}) + + +def _sanitize_slack_name(raw: str) -> str: + """Convert a command name to Slack's native slash-name shape.""" + name = raw.lower() + name = _SLACK_INVALID_CHARS.sub("", name) + name = name.strip("-_") + return name[:_SLACK_NAME_LIMIT] diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 0e5747145a7d0..247511b0045cf 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -142,6 +142,11 @@ def __init__(self, id: int) -> None: # noqa: A002 - matches discord API from pathlib import Path as _Path sys.path.insert(0, str(_Path(__file__).resolve().parents[3])) +try: + from . import choice_picker as _choice_picker +except ImportError: + from plugins.platforms.discord import choice_picker as _choice_picker + def _is_discord_transport_error(exc: BaseException) -> bool: """Return True for connection-shaped send failures (dead/dropping WS). @@ -7939,43 +7944,19 @@ async def send_choice_picker( `/reasoning`, `/fast`, and any future finite-choice command. Each choice dict: ``{"value": str, "label": str, "is_current": bool}``. """ - if not self._client or not DISCORD_AVAILABLE: - return SendResult(success=False, error="Not connected") - - try: - target_id = chat_id - if metadata and metadata.get("thread_id"): - target_id = metadata["thread_id"] - - channel = self._client.get_channel(int(target_id)) - if not channel: - channel = await self._client.fetch_channel(int(target_id)) - - navigation = any(choice.get("full_width") for choice in choices) - first_line = title.splitlines()[0] if title else "Choose an option" - embed = discord.Embed( - title=first_line if navigation else f"⚙ {first_line}", - description="\n".join(title.splitlines()[1:]) or None, - color=discord.Color.blue(), - ) - - view = ChoicePickerView( - choices=choices, - on_choice_selected=on_choice_selected, - allowed_user_ids=self._allowed_user_ids, - allowed_role_ids=self._allowed_role_ids, - requester_user_id=str((metadata or {}).get("requester_user_id") or "") - or None, - navigation=navigation, - ) - - msg = await channel.send(embed=embed, view=view) - view._message = msg # store for on_timeout expiration editing - return SendResult(success=True, message_id=str(msg.id)) - - except Exception as e: - logger.warning("[%s] send_choice_picker failed: %s", self.name, e) - return SendResult(success=False, error=str(e)) + return await _choice_picker.send_choice_picker( + self, + chat_id, + title, + choices, + session_key, + on_choice_selected, + metadata, + discord_sdk=discord, + discord_available=DISCORD_AVAILABLE, + view_class=globals().get("ChoicePickerView"), + logger=logger, + ) def _get_parent_channel_id(self, channel: Any) -> Optional[str]: """Return the parent channel ID for a Discord thread-like channel, if present.""" @@ -9621,116 +9602,14 @@ async def on_timeout(self): pass - class ChoicePickerView(discord.ui.View): - """Flat select-menu view for finite-choice commands (/reasoning, /fast). - - One dropdown, one selection, done — the generic single-level companion - to ``ModelPickerView``. Auth gating mirrors ``ExecApprovalView``. - Times out after 2 minutes. - """ - - def __init__( - self, - choices: list, - on_choice_selected, - allowed_user_ids: set, - allowed_role_ids: Optional[set] = None, - requester_user_id: Optional[str] = None, - navigation: bool = False, - ): - super().__init__(timeout=120) - self.choices = list(choices)[:_DISCORD_SELECT_MAX_OPTIONS] - self.on_choice_selected = on_choice_selected - self.allowed_user_ids = allowed_user_ids - self.allowed_role_ids = allowed_role_ids or set() - self.requester_user_id = requester_user_id - self.navigation = navigation - self.resolved = False - self._message = None - - options = [] - for choice in self.choices: - label = str(choice.get("label") or choice.get("value") or "") - options.append( - discord.SelectOption( - label=_truncate_discord_component_text( - label, _DISCORD_SELECT_FIELD_LIMIT - ), - value=str(choice.get("value") or ""), - description="current" if choice.get("is_current") else None, - ) - ) - select = discord.ui.Select( - placeholder="Choose an option...", - options=options, - ) - select.callback = self._on_select - self.add_item(select) - - def _check_auth(self, interaction: discord.Interaction) -> bool: - if self.requester_user_id and self.requester_user_id != str( - getattr(getattr(interaction, "user", None), "id", "") - ): - return False - return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, - ) - - async def _on_select(self, interaction: discord.Interaction): - if not self._check_auth(interaction): - await interaction.response.send_message( - ( - "⛔ You are not authorized to use this menu." - if self.navigation - else "⛔ You are not authorized to change this setting." - ), - ephemeral=True, - ) - return - if self.resolved: - await interaction.response.defer() - return - self.resolved = True - - value = interaction.data.get("values", [""])[0] - try: - result_text = await self.on_choice_selected( - str(interaction.channel_id), value - ) - except Exception as exc: - logger.error("Choice picker selection failed: %s", exc) - result_text = f"Error applying selection: {exc}" - - embed = discord.Embed( - description=result_text, - color=( - discord.Color.blue() - if self.navigation - else discord.Color.green() - ), - ) - self.clear_items() - self.stop() - await interaction.response.edit_message(embed=embed, view=self) - - async def on_timeout(self): - if self.resolved: - return - msg = self._message - if msg is not None: - try: - embed = discord.Embed( - description=( - "⏱ Menu expired — run the command again." - if self.navigation - else "⏱ Selection expired — no change made." - ), - color=discord.Color.greyple(), - ) - self.clear_items() - await msg.edit(embed=embed, view=self) - except Exception: - pass + ChoicePickerView = _choice_picker.define_choice_picker_view( + discord_sdk=discord, + component_check_auth=_component_check_auth, + truncate_component_text=_truncate_discord_component_text, + logger=logger, + max_options=_DISCORD_SELECT_MAX_OPTIONS, + field_limit=_DISCORD_SELECT_FIELD_LIMIT, + ) class ClarifyChoiceView(discord.ui.View): """Interactive button view for the clarify tool's multiple-choice prompts. diff --git a/plugins/platforms/discord/choice_picker.py b/plugins/platforms/discord/choice_picker.py new file mode 100644 index 0000000000000..8f27a3c3fac99 --- /dev/null +++ b/plugins/platforms/discord/choice_picker.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +"""Finite-choice picker support for the Discord platform adapter.""" + +from typing import Any, Callable, Optional + +from gateway.platforms.base import SendResult + + +# Rebound by ``define_choice_picker_view`` without importing the optional SDK. +discord: Any = None + + +async def send_choice_picker( + adapter: Any, + chat_id: str, + title: str, + choices: list, + session_key: str, + on_choice_selected: Any, + metadata: Optional[dict[str, Any]] = None, + *, + discord_sdk: Any, + discord_available: bool, + view_class: Optional[type], + logger: Any, +) -> SendResult: + """Send a flat select-menu choice picker (one selection to one value).""" + if not adapter._client or not discord_available: + return SendResult(success=False, error="Not connected") + + try: + target_id = chat_id + if metadata and metadata.get("thread_id"): + target_id = metadata["thread_id"] + + channel = adapter._client.get_channel(int(target_id)) + if not channel: + channel = await adapter._client.fetch_channel(int(target_id)) + + navigation = any(choice.get("full_width") for choice in choices) + first_line = title.splitlines()[0] if title else "Choose an option" + embed = discord_sdk.Embed( + title=first_line if navigation else f"⚙ {first_line}", + description="\n".join(title.splitlines()[1:]) or None, + color=discord_sdk.Color.blue(), + ) + + view = view_class( + choices=choices, + on_choice_selected=on_choice_selected, + allowed_user_ids=adapter._allowed_user_ids, + allowed_role_ids=adapter._allowed_role_ids, + requester_user_id=str((metadata or {}).get("requester_user_id") or "") + or None, + navigation=navigation, + ) + + msg = await channel.send(embed=embed, view=view) + view._message = msg + return SendResult(success=True, message_id=str(msg.id)) + except Exception as exc: + logger.warning("[%s] send_choice_picker failed: %s", adapter.name, exc) + return SendResult(success=False, error=str(exc)) + + +def define_choice_picker_view( + *, + discord_sdk: Any, + component_check_auth: Callable[..., bool], + truncate_component_text: Callable[[str, int], str], + logger: Any, + max_options: int, + field_limit: int, +) -> type: + """Build the view class after discord.py is present.""" + global discord + discord = discord_sdk + + class ChoicePickerView(discord.ui.View): + """Flat select-menu view for finite-choice commands (/reasoning, /fast). + + One dropdown, one selection, done — the generic single-level companion + to ``ModelPickerView``. Auth gating mirrors ``ExecApprovalView``. + Times out after 2 minutes. + """ + + def __init__( + self, + choices: list, + on_choice_selected, + allowed_user_ids: set, + allowed_role_ids: Optional[set] = None, + requester_user_id: Optional[str] = None, + navigation: bool = False, + ): + super().__init__(timeout=120) + self.choices = list(choices)[:max_options] + self.on_choice_selected = on_choice_selected + self.allowed_user_ids = allowed_user_ids + self.allowed_role_ids = allowed_role_ids or set() + self.requester_user_id = requester_user_id + self.navigation = navigation + self.resolved = False + self._message = None + + options = [] + for choice in self.choices: + label = str(choice.get("label") or choice.get("value") or "") + options.append( + discord.SelectOption( + label=truncate_component_text(label, field_limit), + value=str(choice.get("value") or ""), + description="current" if choice.get("is_current") else None, + ) + ) + select = discord.ui.Select( + placeholder="Choose an option...", + options=options, + ) + select.callback = self._on_select + self.add_item(select) + + def _check_auth(self, interaction: discord.Interaction) -> bool: + if self.requester_user_id and self.requester_user_id != str( + getattr(getattr(interaction, "user", None), "id", "") + ): + return False + return component_check_auth( + interaction, self.allowed_user_ids, self.allowed_role_ids + ) + + async def _on_select(self, interaction: discord.Interaction): + if not self._check_auth(interaction): + await interaction.response.send_message( + ( + "⛔ You are not authorized to use this menu." + if self.navigation + else "⛔ You are not authorized to change this setting." + ), + ephemeral=True, + ) + return + if self.resolved: + await interaction.response.defer() + return + self.resolved = True + + value = interaction.data.get("values", [""])[0] + try: + result_text = await self.on_choice_selected( + str(interaction.channel_id), value + ) + except Exception as exc: + logger.error("Choice picker selection failed: %s", exc) + result_text = f"Error applying selection: {exc}" + + embed = discord.Embed( + description=result_text, + color=( + discord.Color.blue() if self.navigation else discord.Color.green() + ), + ) + self.clear_items() + self.stop() + await interaction.response.edit_message(embed=embed, view=self) + + async def on_timeout(self): + if self.resolved: + return + msg = self._message + if msg is not None: + try: + embed = discord.Embed( + description=( + "⏱ Menu expired — run the command again." + if self.navigation + else "⏱ Selection expired — no change made." + ), + color=discord.Color.greyple(), + ) + self.clear_items() + await msg.edit(embed=embed, view=self) + except Exception: + pass + + return ChoicePickerView diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 7f04e35abd715..69d76ce6d96b0 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -139,6 +139,11 @@ class _TrustStateStub: # type: ignore[no-redef] ) from gateway.platforms.helpers import ThreadParticipationTracker +try: # sibling module; support both package and flat plugin-dir import + from .message_context import resolve_message_context +except ImportError: # pragma: no cover - plugin loaded outside package context + from message_context import resolve_message_context # type: ignore + logger = logging.getLogger(__name__) _MATRIX_VOICE_WAVEFORM_BINS = 30 @@ -3362,117 +3367,16 @@ async def _resolve_message_context( Returns (body, is_dm, chat_type, thread_id, display_name, source) or None if the message should be dropped (mention gating). """ - identity = await self._resolve_room_identity(room_id) - is_dm = await self._is_dm_room(room_id) - chat_type = "dm" if is_dm else "group" - - thread_id = None - if relates_to.get("rel_type") == "m.thread": - thread_id = relates_to.get("event_id") - - formatted_body = source_content.get("formatted_body") - # m.mentions.user_ids (MSC3952 / Matrix v1.7) — authoritative mention signal. - mentions_block = source_content.get("m.mentions") or {} - mention_user_ids = ( - mentions_block.get("user_ids") if isinstance(mentions_block, dict) else None - ) - is_mentioned = self._is_bot_mentioned(body, formatted_body, mention_user_ids) - - # Require-mention gating. - if not is_dm: - # allowed_rooms check (whitelist — must pass before other gating). - # When set, messages from rooms NOT in this whitelist are silently - # ignored, even if @mentioned. DMs are already excluded above. - if self._allowed_rooms and room_id not in self._allowed_rooms: - logger.debug( - "Matrix: ignoring message %s in %s — room not in " - "MATRIX_ALLOWED_ROOMS whitelist", - event_id, - room_id, - ) - return None - - is_free_room = room_id in self._free_rooms - in_bot_thread = bool(thread_id and thread_id in self._threads) - is_command = body.startswith("/") - if self._require_mention and not is_free_room and not in_bot_thread: - if not is_mentioned and not is_command: - logger.debug( - "Matrix: ignoring message %s in %s — no @mention " - "(set MATRIX_REQUIRE_MENTION=false to disable)", - event_id, - room_id, - ) - return None - - # Thread-level @mention gating: even in a bot-participated thread, - # require @mention when thread_require_mention is enabled. - # Prevents infinite reply loops in multi-agent shared rooms - # where multiple bots all participate in the same thread. - elif (self._thread_require_mention and in_bot_thread - and not is_free_room): - if not is_mentioned: - logger.debug( - "Matrix: ignoring message %s in thread %s — " - "no @mention (thread_require_mention=true)", - event_id, - thread_id, - ) - return None - - # DM mention-thread. - if is_dm and not thread_id and self._dm_mention_threads and is_mentioned: - thread_id = event_id - self._threads.mark(thread_id) - - # Strip mention from body (only when mention-gating is active). - if is_mentioned and self._require_mention: - body = self._strip_mention(body) - - # Auto-thread/session-scope policy. Real Matrix thread roots are - # preserved above; synthetic thread roots are policy-driven. - if not thread_id: - if is_dm: - if self._dm_auto_thread: - thread_id = event_id - self._threads.mark(thread_id) - elif self._matrix_session_scope == "room": - thread_id = None - elif self._matrix_session_scope == "thread": - thread_id = event_id - self._threads.mark(thread_id) - elif self._auto_thread: - thread_id = event_id - self._threads.mark(thread_id) - - display_name = await self._get_display_name(room_id, sender) - source = self.build_source( - chat_id=room_id, - chat_name=identity.display_name, - chat_type=chat_type, - user_id=sender, - user_name=display_name, - thread_id=thread_id, - chat_topic=identity.room_topic, - guild_id=identity.server_name, - parent_chat_id=room_id if thread_id else None, - message_id=event_id, - is_bot=bool(sender and sender == self._user_id), - ) - joined_member_count = getattr(identity, "joined_member_count", None) - source.is_one_to_one = bool( - chat_type == "dm" - and joined_member_count is not None - and joined_member_count <= 2 + return await resolve_message_context( + self, + room_id, + sender, + event_id, + body, + source_content, + relates_to, + logger=logger, ) - source.message_is_edit = False - - if thread_id: - self._threads.mark(thread_id) - - self._background_read_receipt(room_id, event_id) - - return body, is_dm, chat_type, thread_id, display_name, source async def _handle_text_message( self, diff --git a/plugins/platforms/matrix/message_context.py b/plugins/platforms/matrix/message_context.py new file mode 100644 index 0000000000000..af002fe50a562 --- /dev/null +++ b/plugins/platforms/matrix/message_context.py @@ -0,0 +1,127 @@ +"""Inbound Matrix message-context resolution.""" + +from typing import Any, Optional + + +async def resolve_message_context( + self: Any, + room_id: str, + sender: str, + event_id: str, + body: str, + source_content: dict, + relates_to: dict, + *, + logger: Any, +) -> Optional[tuple]: + """Resolve DM, mention, thread, and trust context for an inbound message.""" + identity = await self._resolve_room_identity(room_id) + is_dm = await self._is_dm_room(room_id) + chat_type = "dm" if is_dm else "group" + + thread_id = None + if relates_to.get("rel_type") == "m.thread": + thread_id = relates_to.get("event_id") + + formatted_body = source_content.get("formatted_body") + # m.mentions.user_ids (MSC3952 / Matrix v1.7) — authoritative mention signal. + mentions_block = source_content.get("m.mentions") or {} + mention_user_ids = ( + mentions_block.get("user_ids") if isinstance(mentions_block, dict) else None + ) + is_mentioned = self._is_bot_mentioned(body, formatted_body, mention_user_ids) + + # Require-mention gating. + if not is_dm: + # allowed_rooms check (whitelist — must pass before other gating). + # When set, messages from rooms NOT in this whitelist are silently + # ignored, even if @mentioned. DMs are already excluded above. + if self._allowed_rooms and room_id not in self._allowed_rooms: + logger.debug( + "Matrix: ignoring message %s in %s — room not in " + "MATRIX_ALLOWED_ROOMS whitelist", + event_id, + room_id, + ) + return None + + is_free_room = room_id in self._free_rooms + in_bot_thread = bool(thread_id and thread_id in self._threads) + is_command = body.startswith("/") + if self._require_mention and not is_free_room and not in_bot_thread: + if not is_mentioned and not is_command: + logger.debug( + "Matrix: ignoring message %s in %s — no @mention " + "(set MATRIX_REQUIRE_MENTION=false to disable)", + event_id, + room_id, + ) + return None + + # Thread-level @mention gating: even in a bot-participated thread, + # require @mention when thread_require_mention is enabled. + # Prevents infinite reply loops in multi-agent shared rooms + # where multiple bots all participate in the same thread. + elif self._thread_require_mention and in_bot_thread and not is_free_room: + if not is_mentioned: + logger.debug( + "Matrix: ignoring message %s in thread %s — " + "no @mention (thread_require_mention=true)", + event_id, + thread_id, + ) + return None + + # DM mention-thread. + if is_dm and not thread_id and self._dm_mention_threads and is_mentioned: + thread_id = event_id + self._threads.mark(thread_id) + + # Strip mention from body (only when mention-gating is active). + if is_mentioned and self._require_mention: + body = self._strip_mention(body) + + # Auto-thread/session-scope policy. Real Matrix thread roots are + # preserved above; synthetic thread roots are policy-driven. + if not thread_id: + if is_dm: + if self._dm_auto_thread: + thread_id = event_id + self._threads.mark(thread_id) + elif self._matrix_session_scope == "room": + thread_id = None + elif self._matrix_session_scope == "thread": + thread_id = event_id + self._threads.mark(thread_id) + elif self._auto_thread: + thread_id = event_id + self._threads.mark(thread_id) + + display_name = await self._get_display_name(room_id, sender) + source = self.build_source( + chat_id=room_id, + chat_name=identity.display_name, + chat_type=chat_type, + user_id=sender, + user_name=display_name, + thread_id=thread_id, + chat_topic=identity.room_topic, + guild_id=identity.server_name, + parent_chat_id=room_id if thread_id else None, + message_id=event_id, + is_bot=bool(sender and sender == self._user_id), + ) + joined_member_count = getattr(identity, "joined_member_count", None) + source.is_one_to_one = bool( + chat_type == "dm" + and joined_member_count is not None + and joined_member_count <= 2 + ) + source.message_is_edit = False + + if thread_id: + self._threads.mark(thread_id) + + self._background_read_receipt(room_id, event_id) + + return body, is_dm, chat_type, thread_id, display_name, source diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index d8a0d6da1bf18..142f13b67833d 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -9,7 +9,6 @@ """ import asyncio -import contextvars import inspect import json import logging @@ -64,6 +63,11 @@ except ImportError: # pragma: no cover - plugin loaded outside package context from block_kit import render_blocks, sanitize_blocks # type: ignore +try: # sibling module; support both package and flat plugin-dir import + from .slash_command import _slash_user_id, handle_slash_command +except ImportError: # pragma: no cover - plugin loaded outside package context + from slash_command import _slash_user_id, handle_slash_command # type: ignore + logger = logging.getLogger(__name__) @@ -301,17 +305,6 @@ def _wrap_markdown_tables(text: str) -> str: i += 1 return "\n".join(out) -# ContextVar carrying the user_id of the slash-command invoker. -# Set in _handle_slash_command, read in send() to match the correct -# stashed response_url when multiple users issue commands on the same -# channel concurrently. ContextVars propagate to child asyncio.Tasks -# (Python 3.7+), so the value set in _handle_slash_command's task is -# visible in _process_message_background's child task. -_slash_user_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( - "_slash_user_id", - default=None, -) - @dataclass class _ThreadContextCache: @@ -8465,153 +8458,13 @@ async def _handle_slash_command(self, command: dict) -> None: what's the weather`` — non-slash text is treated as a regular message). """ - slash_name = (command.get("command") or "").lstrip("/").strip() - raw_text = str(command.get("text") or "") - text = raw_text - user_id = command.get("user_id", "") - channel_id = command.get("channel_id", "") - team_id = command.get("team_id", "") - - # Track which workspace owns this channel - if team_id and channel_id: - self._remember_channel_team(channel_id, team_id) - - if slash_name in {"hermes", ""}: - # Legacy /hermes [args] routing + free-form questions. - # Empty slash_name falls into this branch for backward compat - # with any caller that didn't populate command["command"]. - legacy_text = raw_text.strip() - from hermes_cli.commands import slack_subcommand_map - - subcommand_map = slack_subcommand_map() - subcommand_map["compact"] = "/compress" - # Guard against whitespace-only text where ``text`` is truthy but - # ``text.split()`` returns ``[]`` (e.g. user sends ``/hermes ``). - parts = legacy_text.split() if legacy_text else [] - first_word = parts[0] if parts else "" - if first_word in subcommand_map: - rest = legacy_text[len(first_word) :].strip() - text = ( - f"{subcommand_map[first_word]} {rest}".strip() - if rest - else subcommand_map[first_word] - ) - elif legacy_text: - text = legacy_text # Treat as a regular question - else: - text = "/help" - else: - # Native slash — / [args]. Route directly through the - # gateway command dispatcher by prepending the slash. Only the - # command delimiter is nonsemantic: preserve Slack's raw argument - # payload, including meaningful internal/trailing spacing. - text = f"/{slash_name}" if not raw_text else f"/{slash_name} {raw_text}" - - # Slack slash commands can originate from DMs or shared channels. - # Preserve DM semantics only for DM channel IDs; shared channels must - # keep group semantics so different users do not collide into one - # session key. - # - # If Slack includes thread context in the slash payload, preserve it so - # session-scoped commands like `/model ` affect exactly the same - # Slack thread/session that normal messages in that thread use. Without - # this, `/model` from a thread is keyed only by channel+user, so the - # next threaded message misses the override and appears to require - # --global. Slack's native slash-command payloads vary by surface, so - # accept a few known shapes (top-level and nested, preferring a real - # parent-thread anchor over a fallback message timestamp) and otherwise - # leave thread_id unset; users can always use the message-based - # ``!model ...`` thread command path, which carries event.thread_ts. - thread_id = None - _thread_candidates = [command] - for _nested_key in ("message", "container"): - _nested = command.get(_nested_key) - if isinstance(_nested, dict): - _thread_candidates.append(_nested) - for _ts_key in ("thread_ts", "message_ts"): - for _payload in _thread_candidates: - _value = _payload.get(_ts_key) - if _value: - thread_id = str(_value) - break - if thread_id: - break - is_dm = str(channel_id).startswith("D") - if is_dm and self._slack_disable_dms(): - logger.info( - "[Slack] Ignoring slash command from DM because Slack DMs are disabled: channel=%s user=%s", - channel_id, - user_id, - ) - return - source = self.build_source( - chat_id=channel_id, - chat_type="dm" if is_dm else "group", - user_id=user_id, - thread_id=thread_id, - scope_id=team_id or None, + return await handle_slash_command( + self, + command, + logger=logger, + MessageEvent=MessageEvent, + MessageType=MessageType, ) - source.is_one_to_one = is_dm - source.message_is_edit = False - - event = MessageEvent( - text=text, - message_type=( - MessageType.COMMAND if text.startswith("/") else MessageType.TEXT - ), - source=source, - raw_message=command, - ) - - # Stash the Slack response_url so the first reply for this - # channel+user can be routed ephemerally (replaces the initial - # "Running /cmd…" ack shown by handle_hermes_command). - # Only stash for COMMAND events (text starts with "/") — free-form - # questions via "/hermes " must produce public replies so - # the whole channel can see the agent's answer. - response_url = command.get("response_url", "") - if response_url and user_id and channel_id and text.startswith("/"): - context_key = ( - (str(team_id), str(channel_id), str(user_id)) - if team_id - else (str(channel_id), str(user_id)) - ) - self._slash_command_contexts[context_key] = { - "response_url": response_url, - # Kept for the chat.postEphemeral fallback when response_url - # delivery fails — postEphemeral needs an explicit user. - "user_id": user_id, - "ts": time.monotonic(), - } - if len(self._slash_command_contexts) > self._SLASH_CTX_MAX: - # TTL cleanup normally runs on lookup, but contexts stashed - # for replies that never happen (agent error, ephemeral-only - # command) are never looked up — purge expired entries, then - # fall back to oldest-stash-first eviction if still over cap. - now_ts = time.monotonic() - for stale_key in [ - k - for k, v in self._slash_command_contexts.items() - if now_ts - v["ts"] > self._SLASH_CTX_TTL - ]: - del self._slash_command_contexts[stale_key] - if len(self._slash_command_contexts) > self._SLASH_CTX_MAX: - excess = ( - len(self._slash_command_contexts) - self._SLASH_CTX_MAX // 2 - ) - for old_key in sorted( - self._slash_command_contexts, - key=lambda k: self._slash_command_contexts[k]["ts"], - )[:excess]: - del self._slash_command_contexts[old_key] - - # Set the ContextVar so send() can match the correct stashed - # response_url even when multiple users slash concurrently. - _slash_user_id_token = _slash_user_id.set(user_id or None) - try: - await self.handle_message(event) - finally: - _slash_user_id.reset(_slash_user_id_token) def _build_thread_session_key( self, diff --git a/plugins/platforms/slack/slash_command.py b/plugins/platforms/slack/slash_command.py new file mode 100644 index 0000000000000..fa1740e27d8b1 --- /dev/null +++ b/plugins/platforms/slack/slash_command.py @@ -0,0 +1,171 @@ +"""Slack slash-command intake and invoker context.""" + +import contextvars +import time +from typing import Any, Optional + + +# ContextVar carrying the user_id of the slash-command invoker. +# Set in handle_slash_command, read in SlackAdapter.send() to match the correct +# stashed response_url when multiple users issue commands on the same channel +# concurrently. ContextVars propagate to child asyncio tasks. +_slash_user_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( + "_slash_user_id", + default=None, +) + + +async def handle_slash_command( + self: Any, + command: dict, + *, + logger: Any, + MessageEvent: Any, + MessageType: Any, +) -> None: + """Build and dispatch one Slack slash-command event.""" + slash_name = (command.get("command") or "").lstrip("/").strip() + raw_text = str(command.get("text") or "") + text = raw_text + user_id = command.get("user_id", "") + channel_id = command.get("channel_id", "") + team_id = command.get("team_id", "") + + # Track which workspace owns this channel + if team_id and channel_id: + self._remember_channel_team(channel_id, team_id) + + if slash_name in {"hermes", ""}: + # Legacy /hermes [args] routing + free-form questions. + # Empty slash_name falls into this branch for backward compat + # with any caller that didn't populate command["command"]. + legacy_text = raw_text.strip() + from hermes_cli.commands import slack_subcommand_map + + subcommand_map = slack_subcommand_map() + subcommand_map["compact"] = "/compress" + # Guard against whitespace-only text where ``text`` is truthy but + # ``text.split()`` returns ``[]`` (e.g. user sends ``/hermes ``). + parts = legacy_text.split() if legacy_text else [] + first_word = parts[0] if parts else "" + if first_word in subcommand_map: + rest = legacy_text[len(first_word) :].strip() + text = ( + f"{subcommand_map[first_word]} {rest}".strip() + if rest + else subcommand_map[first_word] + ) + elif legacy_text: + text = legacy_text # Treat as a regular question + else: + text = "/help" + else: + # Native slash — / [args]. Route directly through the + # gateway command dispatcher by prepending the slash. Only the command + # delimiter is nonsemantic: preserve Slack's raw argument payload, + # including meaningful internal/trailing spacing. + text = f"/{slash_name}" if not raw_text else f"/{slash_name} {raw_text}" + + # Slack slash commands can originate from DMs or shared channels. + # Preserve DM semantics only for DM channel IDs; shared channels must + # keep group semantics so different users do not collide into one + # session key. + # + # If Slack includes thread context in the slash payload, preserve it so + # session-scoped commands like `/model ` affect exactly the same + # Slack thread/session that normal messages in that thread use. Without + # this, `/model` from a thread is keyed only by channel+user, so the + # next threaded message misses the override and appears to require + # --global. Slack's native slash-command payloads vary by surface, so + # accept a few known shapes (top-level and nested, preferring a real + # parent-thread anchor over a fallback message timestamp) and otherwise + # leave thread_id unset; users can always use the message-based + # ``!model ...`` thread command path, which carries event.thread_ts. + thread_id = None + _thread_candidates = [command] + for _nested_key in ("message", "container"): + _nested = command.get(_nested_key) + if isinstance(_nested, dict): + _thread_candidates.append(_nested) + for _ts_key in ("thread_ts", "message_ts"): + for _payload in _thread_candidates: + _value = _payload.get(_ts_key) + if _value: + thread_id = str(_value) + break + if thread_id: + break + is_dm = str(channel_id).startswith("D") + if is_dm and self._slack_disable_dms(): + logger.info( + "[Slack] Ignoring slash command from DM because Slack DMs are disabled: channel=%s user=%s", + channel_id, + user_id, + ) + return + source = self.build_source( + chat_id=channel_id, + chat_type="dm" if is_dm else "group", + user_id=user_id, + thread_id=thread_id, + scope_id=team_id or None, + ) + source.is_one_to_one = is_dm + source.message_is_edit = False + + event = MessageEvent( + text=text, + message_type=( + MessageType.COMMAND if text.startswith("/") else MessageType.TEXT + ), + source=source, + raw_message=command, + ) + + # Stash the Slack response_url so the first reply for this + # channel+user can be routed ephemerally (replaces the initial + # "Running /cmd…" ack shown by handle_hermes_command). + # Only stash for COMMAND events (text starts with "/") — free-form + # questions via "/hermes " must produce public replies so + # the whole channel can see the agent's answer. + response_url = command.get("response_url", "") + if response_url and user_id and channel_id and text.startswith("/"): + context_key = ( + (str(team_id), str(channel_id), str(user_id)) + if team_id + else (str(channel_id), str(user_id)) + ) + self._slash_command_contexts[context_key] = { + "response_url": response_url, + # Kept for the chat.postEphemeral fallback when response_url + # delivery fails — postEphemeral needs an explicit user. + "user_id": user_id, + "ts": time.monotonic(), + } + if len(self._slash_command_contexts) > self._SLASH_CTX_MAX: + # TTL cleanup normally runs on lookup, but contexts stashed + # for replies that never happen (agent error, ephemeral-only + # command) are never looked up — purge expired entries, then + # fall back to oldest-stash-first eviction if still over cap. + now_ts = time.monotonic() + for stale_key in [ + k + for k, v in self._slash_command_contexts.items() + if now_ts - v["ts"] > self._SLASH_CTX_TTL + ]: + del self._slash_command_contexts[stale_key] + if len(self._slash_command_contexts) > self._SLASH_CTX_MAX: + excess = len(self._slash_command_contexts) - self._SLASH_CTX_MAX // 2 + for old_key in sorted( + self._slash_command_contexts, + key=lambda k: self._slash_command_contexts[k]["ts"], + )[:excess]: + del self._slash_command_contexts[old_key] + + # Set the ContextVar so send() can match the correct stashed + # response_url even when multiple users slash concurrently. + _slash_user_id_token = _slash_user_id.set(user_id or None) + try: + await self.handle_message(event) + finally: + _slash_user_id.reset(_slash_user_id_token) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index c42ccb95ea44f..578e785a89d07 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -214,6 +214,11 @@ class _MockContextTypes: from pathlib import Path as _Path sys.path.insert(0, str(_Path(__file__).resolve().parents[3])) +try: + from . import choice_picker as _choice_picker +except ImportError: + from plugins.platforms.telegram import choice_picker as _choice_picker + from gateway.authz_mixin import _coerce_allow_set from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( @@ -6737,128 +6742,34 @@ async def send_choice_picker( `/reasoning`, `/fast`, and any future finite-choice command. Each choice dict: ``{"value": str, "label": str, "is_current": bool}``. """ - if not self._bot: - return SendResult(success=False, error="Not connected") - - try: - buttons = [] - for i, choice in enumerate(choices): - label = str(choice.get("label") or choice.get("value") or "") - if choice.get("is_current"): - label = f"✓ {label}" - buttons.append( - InlineKeyboardButton(label, callback_data=f"cp:{i}") - ) - if not buttons: - return SendResult(success=False, error="No choices") - row_size = 1 if any(choice.get("full_width") for choice in choices) else 2 - # Settings stay compact; navigation choices can request a full row. - keyboard = InlineKeyboardMarkup( - [buttons[i:i + row_size] for i in range(0, len(buttons), row_size)] - ) - - thread_id = metadata.get("thread_id") if metadata else None - reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode) - msg = await self._send_message_with_thread_fallback( - chat_id=normalize_telegram_chat_id(chat_id), - text=self.format_message(title), - parse_mode=ParseMode.MARKDOWN_V2, - reply_markup=keyboard, - reply_to_message_id=reply_to_id, - **self._thread_kwargs_for_send( - chat_id, - thread_id, - metadata, - reply_to_message_id=reply_to_id, - reply_to_mode=self._reply_to_mode - ), - **self._link_preview_kwargs(), - ) - - self._choice_picker_state[str(chat_id)] = { - "expires_at": time.monotonic() + 120, - "msg_id": msg.message_id, - "choices": choices, - "requester_user_id": str((metadata or {}).get("requester_user_id") or ""), - "session_key": session_key, - "on_choice_selected": on_choice_selected, - } - return SendResult(success=True, message_id=str(msg.message_id)) - except Exception as e: - logger.warning("[%s] send_choice_picker failed: %s", self.name, _redact_telegram_error_text(e)) - return SendResult(success=False, error=_redact_telegram_error_text(e)) + return await _choice_picker.send_choice_picker( + self, + chat_id, + title, + choices, + session_key, + on_choice_selected, + metadata, + inline_keyboard_button=InlineKeyboardButton, + inline_keyboard_markup=InlineKeyboardMarkup, + parse_mode=ParseMode, + normalize_chat_id=normalize_telegram_chat_id, + redact_error=_redact_telegram_error_text, + logger=logger, + ) async def _handle_choice_picker_callback( self, query, data: str, chat_id: str ) -> None: """Handle choice picker button taps (cp:).""" - state = self._choice_picker_state.get(chat_id) - if not state: - await query.answer(text="Picker expired — run the command again.") - return - - # Same authorization gate as approval buttons: unauthorized users in a - # shared group must not flip session/config state via someone else's - # picker message. - query_message = getattr(query, "message", None) - query_chat = getattr(query_message, "chat", None) - query_message_id = getattr(query_message, "message_id", None) - if query_message_id != state.get("msg_id"): - await query.answer(text="This menu has expired. Run the command again.") - return - if time.monotonic() > float(state.get("expires_at") or 0): - self._choice_picker_state.pop(chat_id, None) - await query.answer(text="This menu has expired. Run the command again.") - return - requester_user_id = str(state.get("requester_user_id") or "") - if requester_user_id and requester_user_id != str( - getattr(query.from_user, "id", "") - ): - await query.answer(text="⛔ This menu belongs to another user.") - return - if not self._is_callback_user_authorized( - str(getattr(query.from_user, "id", "")), - chat_id=getattr(query_message, "chat_id", None), - chat_type=str(getattr(query_chat, "type", None)) if getattr(query_chat, "type", None) is not None else None, - thread_id=str(getattr(query_message, "message_thread_id", None)) if getattr(query_message, "message_thread_id", None) is not None else None, - user_name=getattr(query.from_user, "first_name", None), - ): - await query.answer(text="⛔ You are not authorized to change this setting.") - return - - try: - idx = int(data[3:]) - choice = state["choices"][idx] - except (ValueError, IndexError): - await query.answer(text="Invalid selection.") - return - - callback = state.get("on_choice_selected") - if not callback: - await query.answer(text="Picker expired.") - return - - try: - result_text = await callback(chat_id, str(choice.get("value") or "")) - except Exception as exc: - logger.error("Choice picker selection failed: %s", exc) - result_text = f"Error applying selection: {exc}" - - try: - await query.edit_message_text( - text=self.format_message(result_text), - parse_mode=ParseMode.MARKDOWN_V2, - reply_markup=None, - ) - except Exception: - try: - await query.edit_message_text( - text=result_text, parse_mode=None, reply_markup=None, - ) - except Exception: - pass - await query.answer() - self._choice_picker_state.pop(chat_id, None) + await _choice_picker.handle_choice_picker_callback( + self, + query, + data, + chat_id, + parse_mode=ParseMode, + logger=logger, + ) _MODEL_PAGE_SIZE = 8 diff --git a/plugins/platforms/telegram/choice_picker.py b/plugins/platforms/telegram/choice_picker.py new file mode 100644 index 0000000000000..ff6c7b064fa51 --- /dev/null +++ b/plugins/platforms/telegram/choice_picker.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +"""Finite-choice picker support for the Telegram platform adapter.""" + +import time +from typing import Any, Callable, Optional + +from gateway.platforms.base import SendResult + + +async def send_choice_picker( + adapter: Any, + chat_id: str, + title: str, + choices: list, + session_key: str, + on_choice_selected: Any, + metadata: Optional[dict[str, Any]] = None, + *, + inline_keyboard_button: Any, + inline_keyboard_markup: Any, + parse_mode: Any, + normalize_chat_id: Callable[[Any], Any], + redact_error: Callable[[Any], str], + logger: Any, +) -> SendResult: + """Send a flat inline-keyboard choice picker (one tap to one value).""" + if not adapter._bot: + return SendResult(success=False, error="Not connected") + + try: + buttons = [] + for i, choice in enumerate(choices): + label = str(choice.get("label") or choice.get("value") or "") + if choice.get("is_current"): + label = f"✓ {label}" + buttons.append(inline_keyboard_button(label, callback_data=f"cp:{i}")) + if not buttons: + return SendResult(success=False, error="No choices") + row_size = 1 if any(choice.get("full_width") for choice in choices) else 2 + keyboard = inline_keyboard_markup([ + buttons[i : i + row_size] for i in range(0, len(buttons), row_size) + ]) + + thread_id = metadata.get("thread_id") if metadata else None + reply_to_id = adapter._reply_to_message_id_for_send( + None, metadata, reply_to_mode=adapter._reply_to_mode + ) + msg = await adapter._send_message_with_thread_fallback( + chat_id=normalize_chat_id(chat_id), + text=adapter.format_message(title), + parse_mode=parse_mode.MARKDOWN_V2, + reply_markup=keyboard, + reply_to_message_id=reply_to_id, + **adapter._thread_kwargs_for_send( + chat_id, + thread_id, + metadata, + reply_to_message_id=reply_to_id, + reply_to_mode=adapter._reply_to_mode, + ), + **adapter._link_preview_kwargs(), + ) + + adapter._choice_picker_state[str(chat_id)] = { + "expires_at": time.monotonic() + 120, + "msg_id": msg.message_id, + "choices": choices, + "requester_user_id": str((metadata or {}).get("requester_user_id") or ""), + "session_key": session_key, + "on_choice_selected": on_choice_selected, + } + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as exc: + logger.warning( + "[%s] send_choice_picker failed: %s", + adapter.name, + redact_error(exc), + ) + return SendResult(success=False, error=redact_error(exc)) + + +async def handle_choice_picker_callback( + adapter: Any, + query: Any, + data: str, + chat_id: str, + *, + parse_mode: Any, + logger: Any, +) -> None: + """Handle choice picker button taps (cp:).""" + state = adapter._choice_picker_state.get(chat_id) + if not state: + await query.answer(text="Picker expired — run the command again.") + return + + query_message = getattr(query, "message", None) + query_chat = getattr(query_message, "chat", None) + query_message_id = getattr(query_message, "message_id", None) + if query_message_id != state.get("msg_id"): + await query.answer(text="This menu has expired. Run the command again.") + return + if time.monotonic() > float(state.get("expires_at") or 0): + adapter._choice_picker_state.pop(chat_id, None) + await query.answer(text="This menu has expired. Run the command again.") + return + requester_user_id = str(state.get("requester_user_id") or "") + if requester_user_id and requester_user_id != str( + getattr(query.from_user, "id", "") + ): + await query.answer(text="⛔ This menu belongs to another user.") + return + if not adapter._is_callback_user_authorized( + str(getattr(query.from_user, "id", "")), + chat_id=getattr(query_message, "chat_id", None), + chat_type=( + str(getattr(query_chat, "type", None)) + if getattr(query_chat, "type", None) is not None + else None + ), + thread_id=( + str(getattr(query_message, "message_thread_id", None)) + if getattr(query_message, "message_thread_id", None) is not None + else None + ), + user_name=getattr(query.from_user, "first_name", None), + ): + await query.answer(text="⛔ You are not authorized to change this setting.") + return + + try: + idx = int(data[3:]) + choice = state["choices"][idx] + except (ValueError, IndexError): + await query.answer(text="Invalid selection.") + return + + callback = state.get("on_choice_selected") + if not callback: + await query.answer(text="Picker expired.") + return + + try: + result_text = await callback(chat_id, str(choice.get("value") or "")) + except Exception as exc: + logger.error("Choice picker selection failed: %s", exc) + result_text = f"Error applying selection: {exc}" + + try: + await query.edit_message_text( + text=adapter.format_message(result_text), + parse_mode=parse_mode.MARKDOWN_V2, + reply_markup=None, + ) + except Exception: + try: + await query.edit_message_text( + text=result_text, + parse_mode=None, + reply_markup=None, + ) + except Exception: + pass + await query.answer() + adapter._choice_picker_state.pop(chat_id, None) diff --git a/tests/gateway/platforms/test_api_server_room_controls.py b/tests/gateway/platforms/test_api_server_room_controls.py index c660498ea8d66..48c1ee208720c 100644 --- a/tests/gateway/platforms/test_api_server_room_controls.py +++ b/tests/gateway/platforms/test_api_server_room_controls.py @@ -90,9 +90,7 @@ def control_api(tmp_path, monkeypatch): "tui_gateway.methods_groups.get_hosted_room_service", lambda: service, ) - adapter = APIServerAdapter( - PlatformConfig(enabled=True, extra={"key": "sk-secret"}) - ) + adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={"key": "sk-secret"})) app = web.Application() for method, path, handler in api_server_room_controls._http_routes(adapter): app.router.add_route(method, path, handler) @@ -170,12 +168,15 @@ async def test_read_send_stop_and_retry_are_scoped_and_replay_safe(control_api): ) assert retried.status == retry_replay.status == 200 assert service.retried == [] - assert len( - hosted_room_controls.load_pending_control_retries( - service.db_path, - room_id="room-1", + assert ( + len( + hosted_room_controls.load_pending_control_retries( + service.db_path, + room_id="room-1", + ) ) - ) == 1 + == 1 + ) stopped = await client.post( "/v1/room-controls/room-1", diff --git a/tests/gateway/test_choice_picker.py b/tests/gateway/test_choice_picker.py index a2c9a52961a57..d3d335c6124f3 100644 --- a/tests/gateway/test_choice_picker.py +++ b/tests/gateway/test_choice_picker.py @@ -144,4 +144,3 @@ async def test_fast_picker_selection_is_session_scoped(self, tmp_path, monkeypat assert runner._session_service_tier_overrides assert not (tmp_path / "config.yaml").exists() - diff --git a/tests/gateway/test_desktop_room_mailbox.py b/tests/gateway/test_desktop_room_mailbox.py index f37e687569dba..9c4b93e3193d3 100644 --- a/tests/gateway/test_desktop_room_mailbox.py +++ b/tests/gateway/test_desktop_room_mailbox.py @@ -16,10 +16,7 @@ def __call__(self) -> float: def authorities(*room_ids: str, token: str = "authority:one") -> list[dict]: - return [ - {"room_id": room_id, "authority_token": token} - for room_id in room_ids - ] + return [{"room_id": room_id, "authority_token": token} for room_id in room_ids] def authority_commitment(token: str = "authority:one") -> str: @@ -335,15 +332,18 @@ def test_live_claim_can_be_renewed(tmp_path): clock.value += 5 assert renewed["lease_token"] == claimed["lease_token"] - assert mailbox.complete_command( - db, - consumer_id="desktop:first", - command_id="messaging:renew", - lease_token=claimed["lease_token"], - success=True, - result={"thread_id": "thread-1"}, - clock=clock, - )["state"] == "completed" + assert ( + mailbox.complete_command( + db, + consumer_id="desktop:first", + command_id="messaging:renew", + lease_token=claimed["lease_token"], + success=True, + result={"thread_id": "thread-1"}, + clock=clock, + )["state"] + == "completed" + ) def test_presence_expires_without_deleting_pending_work(tmp_path): @@ -400,12 +400,15 @@ def test_authority_token_fences_a_cold_desktop_after_owner_expiry(tmp_path): ) clock.value += 6 - assert mailbox.claim_commands( - db, - consumer_id="desktop:cold", - room_authorities=authorities("room-1", token="authority:wrong"), - clock=clock, - ) == [] + assert ( + mailbox.claim_commands( + db, + consumer_id="desktop:cold", + room_authorities=authorities("room-1", token="authority:wrong"), + clock=clock, + ) + == [] + ) reclaimed = mailbox.claim_commands( db, consumer_id="desktop:owner", @@ -426,11 +429,14 @@ def test_claim_cannot_establish_room_authority(tmp_path): payload={"message": "hello"}, ) - assert mailbox.claim_commands( - db, - consumer_id="desktop:untrusted", - room_authorities=authorities("room-1", token="authority:guessed"), - ) == [] + assert ( + mailbox.claim_commands( + db, + consumer_id="desktop:untrusted", + room_authorities=authorities("room-1", token="authority:guessed"), + ) + == [] + ) claimed = mailbox.claim_commands( db, @@ -451,13 +457,18 @@ def test_projected_authority_commitment_is_idempotent_and_fenced(tmp_path): [{"room_id": "room-1", "authority_hash": authority_commitment()}], ) == ["room-1"] - assert mailbox.register_projected_authorities( - db, - [{ - "room_id": "room-1", - "authority_hash": authority_commitment("authority:other"), - }], - ) == [] + assert ( + mailbox.register_projected_authorities( + db, + [ + { + "room_id": "room-1", + "authority_hash": authority_commitment("authority:other"), + } + ], + ) + == [] + ) assert mailbox.register_projected_authorities( db, @@ -553,13 +564,16 @@ def test_renew_keeps_room_ownership_alive_for_long_turn(tmp_path): ) assert mailbox.room_available(db, "room-1", clock=clock) is True - assert mailbox.claim_commands( - db, - consumer_id="desktop:other", - room_authorities=authorities("room-1"), - actions=["stop"], - clock=clock, - ) == [] + assert ( + mailbox.claim_commands( + db, + consumer_id="desktop:other", + room_authorities=authorities("room-1"), + actions=["stop"], + clock=clock, + ) + == [] + ) def test_default_presence_overlaps_the_minute_desktop_backstop(tmp_path): @@ -662,21 +676,27 @@ def test_presence_refresh_allows_secret_proven_takeover_after_expiry(tmp_path): presence_ttl=5, clock=clock, ) == ["room-1"] - assert mailbox.refresh_presence( - db, - consumer_id="desktop:second", - room_authorities=authorities("room-1"), - presence_ttl=5, - clock=clock, - ) == [] + assert ( + mailbox.refresh_presence( + db, + consumer_id="desktop:second", + room_authorities=authorities("room-1"), + presence_ttl=5, + clock=clock, + ) + == [] + ) clock.value += 6 - assert mailbox.refresh_presence( - db, - consumer_id="desktop:second", - room_authorities=authorities("room-1", token="authority:wrong"), - clock=clock, - ) == [] + assert ( + mailbox.refresh_presence( + db, + consumer_id="desktop:second", + room_authorities=authorities("room-1", token="authority:wrong"), + clock=clock, + ) + == [] + ) assert mailbox.refresh_presence( db, consumer_id="desktop:second", @@ -699,12 +719,15 @@ def test_pending_command_expires_instead_of_running_days_later(tmp_path): ) clock.value += mailbox.PENDING_TTL_SECONDS + 1 - assert mailbox.claim_commands( - db, - consumer_id="desktop:first", - room_authorities=authorities("room-1"), - clock=clock, - ) == [] + assert ( + mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + == [] + ) state = mailbox.latest_command_states(db, ["room-1"])["room-1"] assert state["state"] == "failed" assert state["result"]["code"] == "command_expired" @@ -726,12 +749,15 @@ def test_retry_requeues_all_bounded_expired_commands_oldest_first(tmp_path): clock.value += 1 clock.value += mailbox.PENDING_TTL_SECONDS + 1 - assert mailbox.claim_commands( - db, - consumer_id="desktop:first", - room_authorities=authorities("room-1"), - clock=clock, - ) == [] + assert ( + mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + == [] + ) frozen = mailbox.retryable_command_ids(db, room_id="room-1") assert frozen == ("messaging:stale-0", "messaging:stale-1") diff --git a/tests/gateway/test_group_chat_matrix_adapter.py b/tests/gateway/test_group_chat_matrix_adapter.py index e9d0b49edc96c..8ce2855b5e2b2 100644 --- a/tests/gateway/test_group_chat_matrix_adapter.py +++ b/tests/gateway/test_group_chat_matrix_adapter.py @@ -17,9 +17,11 @@ async def test_named_two_member_dm_stamps_one_to_one_proof(): adapter._dm_rooms = {"!named_dm:ex.org": True} adapter._client = MagicMock() adapter._client.get_state_event = AsyncMock( - side_effect=lambda _room_id, event_type: {"name": "Alice & Bot"} - if event_type == "m.room.name" - else (_ for _ in ()).throw(Exception("no alias")) + side_effect=lambda _room_id, event_type: ( + {"name": "Alice & Bot"} + if event_type == "m.room.name" + else (_ for _ in ()).throw(Exception("no alias")) + ) ) adapter._client.state_store = MagicMock() adapter._client.state_store.get_members = AsyncMock( diff --git a/tests/gateway/test_group_chat_slack_adapter.py b/tests/gateway/test_group_chat_slack_adapter.py index d7cf3b513189b..4dc8415540fd2 100644 --- a/tests/gateway/test_group_chat_slack_adapter.py +++ b/tests/gateway/test_group_chat_slack_adapter.py @@ -40,14 +40,12 @@ async def test_users_info_bot_classification_reaches_session_source(adapter): @pytest.mark.asyncio async def test_channel_slash_command_uses_group_session_semantics(adapter): - await adapter._handle_slash_command( - { - "text": "hello", - "user_id": "U123", - "channel_id": "C123", - "team_id": "T123", - } - ) + await adapter._handle_slash_command({ + "text": "hello", + "user_id": "U123", + "channel_id": "C123", + "team_id": "T123", + }) event = adapter.handle_message.await_args.args[0] assert event.source.chat_type == "group" @@ -59,34 +57,30 @@ async def test_channel_slash_command_uses_group_session_semantics(adapter): @pytest.mark.asyncio async def test_message_edit_stamps_untrusted_command_provenance(adapter): - await adapter._handle_slack_message( - { - "text": "whats the rapchat summary for last 12 hours", - "user": "U_USER", - "channel": "C123", - "channel_type": "mpim", - "team": "T123", - "ts": "1234567890.000001", - } - ) + await adapter._handle_slack_message({ + "text": "whats the rapchat summary for last 12 hours", + "user": "U_USER", + "channel": "C123", + "channel_type": "mpim", + "team": "T123", + "ts": "1234567890.000001", + }) adapter.handle_message.assert_not_called() - await adapter._handle_slack_message( - { - "subtype": "message_changed", + await adapter._handle_slack_message({ + "subtype": "message_changed", + "channel": "C123", + "channel_type": "mpim", + "team": "T123", + "ts": "1234567890.000001", + "message": { + "text": "<@U_BOT> whats the rapchat summary for last 12 hours", + "user": "U_USER", "channel": "C123", - "channel_type": "mpim", - "team": "T123", "ts": "1234567890.000001", - "message": { - "text": "<@U_BOT> whats the rapchat summary for last 12 hours", - "user": "U_USER", - "channel": "C123", - "ts": "1234567890.000001", - "edited": {"user": "U_USER", "ts": "1234567899.000001"}, - }, - } - ) + "edited": {"user": "U_USER", "ts": "1234567899.000001"}, + }, + }) event = adapter.handle_message.call_args[0][0] assert event.source.is_one_to_one is False @@ -96,16 +90,14 @@ async def test_message_edit_stamps_untrusted_command_provenance(adapter): @pytest.mark.asyncio async def test_room_control_keeps_slack_trigger_for_idempotency(adapter): - await adapter._handle_slash_command( - { - "command": "/hermes", - "text": "group 1 send hello", - "trigger_id": "trigger-room-1", - "user_id": "U1", - "channel_id": "C1", - "team_id": "T1", - } - ) + await adapter._handle_slash_command({ + "command": "/hermes", + "text": "group 1 send hello", + "trigger_id": "trigger-room-1", + "user_id": "U1", + "channel_id": "C1", + "team_id": "T1", + }) event = adapter.handle_message.call_args[0][0] assert event.text == "/group 1 send hello" diff --git a/tests/gateway/test_hosted_room_control_client.py b/tests/gateway/test_hosted_room_control_client.py index b3dc823260702..6f3a13a230caa 100644 --- a/tests/gateway/test_hosted_room_control_client.py +++ b/tests/gateway/test_hosted_room_control_client.py @@ -28,23 +28,17 @@ def _reply(self, payload): self.wfile.write(data) def do_GET(self): - type(self).requests.append( - ("GET", self.path, dict(self.headers), None) - ) + type(self).requests.append(("GET", self.path, dict(self.headers), None)) self._reply({"room": {"room_id": "room-1"}, "events": []}) def do_POST(self): length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length) or b"{}") - type(self).requests.append( - ("POST", self.path, dict(self.headers), body) - ) + type(self).requests.append(("POST", self.path, dict(self.headers), body)) self._reply({"action": body["action"], "summary": {"events": []}}) def do_DELETE(self): - type(self).requests.append( - ("DELETE", self.path, dict(self.headers), None) - ) + type(self).requests.append(("DELETE", self.path, dict(self.headers), None)) self._reply({"revoked": 1}) def log_message(self, *_args): @@ -86,12 +80,15 @@ def test_summary_and_mutation_keep_the_token_in_headers(control_server): client = RoomControlHTTPClient(_link(control_server)) assert client.summary()["room"]["room_id"] == "room-1" - assert client.mutate( - action="send", - command_id="command-1", - text="hello", - actor_display_name="Signal", - )["action"] == "send" + assert ( + client.mutate( + action="send", + command_id="command-1", + text="hello", + actor_display_name="Signal", + )["action"] + == "send" + ) client.revoke() assert [request[0] for request in ControlHandler.requests] == [ @@ -128,9 +125,7 @@ def log_message(self, *_args): thread.join(timeout=5) -def test_stored_peer_revoke_contacts_home_before_erasing_bearer( - tmp_path, monkeypatch -): +def test_stored_peer_revoke_contacts_home_before_erasing_bearer(tmp_path, monkeypatch): db = tmp_path / "state.db" saved = hosted_room_controls.save_peer_control_link( db, @@ -152,10 +147,13 @@ def test_stored_peer_revoke_contacts_home_before_erasing_bearer( lambda self: revoked.append(self.link.room_id), ) - assert revoke_stored_peer_control( - db, room_id="room-1", member_id="member-peer" - ) == 1 + assert ( + revoke_stored_peer_control(db, room_id="room-1", member_id="member-peer") == 1 + ) assert revoked == [saved.link.room_id] - assert hosted_room_controls.load_peer_control_links( - db, include_inactive=True, now=30 - ).links == () + assert ( + hosted_room_controls.load_peer_control_links( + db, include_inactive=True, now=30 + ).links + == () + ) diff --git a/tests/gateway/test_hosted_room_controls.py b/tests/gateway/test_hosted_room_controls.py index 3c40bc808375a..77dc4c5623244 100644 --- a/tests/gateway/test_hosted_room_controls.py +++ b/tests/gateway/test_hosted_room_controls.py @@ -88,9 +88,7 @@ def test_home_stores_only_sha256_and_never_exposes_token(tmp_path): assert issued.control_token.encode() not in db.read_bytes() -def test_home_invitation_replay_recovers_the_same_opaque_token( - tmp_path, monkeypatch -): +def test_home_invitation_replay_recovers_the_same_opaque_token(tmp_path, monkeypatch): db = tmp_path / "state.db" _create_room(db) monkeypatch.setattr( @@ -112,9 +110,12 @@ def test_home_invitation_replay_recovers_the_same_opaque_token( assert replay.control_token == first.control_token with sqlite3.connect(db) as conn: - assert conn.execute( - "SELECT COUNT(*) FROM hosted_room_control_tokens" - ).fetchone()[0] == 1 + assert ( + conn.execute("SELECT COUNT(*) FROM hosted_room_control_tokens").fetchone()[ + 0 + ] + == 1 + ) with pytest.raises(controls.HostedRoomControlConflictError): controls.issue_home_control_token( db, @@ -583,7 +584,9 @@ def test_failed_retry_rotation_does_not_starve_newer_commands(tmp_path): now=20 + index, ) first = controls.load_pending_control_retries(db, room_id="room-1", limit=8) - assert [item.command_id for item in first] == [f"retry-{index}" for index in range(8)] + assert [item.command_id for item in first] == [ + f"retry-{index}" for index in range(8) + ] for item in first: assert controls.defer_control_retry( db, diff --git a/tests/gateway/test_hosted_room_messaging.py b/tests/gateway/test_hosted_room_messaging.py index 3768729c4bd59..a45b6adaf815b 100644 --- a/tests/gateway/test_hosted_room_messaging.py +++ b/tests/gateway/test_hosted_room_messaging.py @@ -11,7 +11,12 @@ import pytest -from gateway import hosted_room_controls, hosted_room_driver, hosted_room_messaging, hosted_rooms +from gateway import ( + hosted_room_controls, + hosted_room_driver, + hosted_room_messaging, + hosted_rooms, +) from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig from gateway.hosted_room_messaging import ( MessagingRoomBackend, @@ -197,12 +202,10 @@ def test_secondary_profile_only_lists_rooms_in_its_frozen_roster(tmp_path): "research-room", } assert [ - room["room_id"] - for room in list_messaging_rooms(service, profile="ops") + room["room_id"] for room in list_messaging_rooms(service, profile="ops") ] == ["release-room"] assert [ - room["room_id"] - for room in list_messaging_rooms(service, profile="research") + room["room_id"] for room in list_messaging_rooms(service, profile="research") ] == ["research-room"] @@ -234,8 +237,16 @@ def _event( user_id=user_id, user_name="Display Name", message_id=message_id, - media_urls=media_urls if media_urls is not None else ["/tmp/image.png"] if media else [], - media_types=media_types if media_types is not None else ["image/png"] if media else [], + media_urls=media_urls + if media_urls is not None + else ["/tmp/image.png"] + if media + else [], + media_types=media_types + if media_types is not None + else ["image/png"] + if media + else [], source=source, ) @@ -244,9 +255,7 @@ def _runner(*, platform: Platform = Platform.SIGNAL, extra=None): from gateway.run import GatewayRunner runner = object.__new__(GatewayRunner) - effective_extra = ( - {"allow_admin_from": ["user-1"]} if extra is None else extra - ) + effective_extra = {"allow_admin_from": ["user-1"]} if extra is None else extra runner.config = GatewayConfig( platforms={ platform: PlatformConfig( @@ -258,7 +267,9 @@ def _runner(*, platform: Platform = Platform.SIGNAL, extra=None): ) runner.adapters = { platform: SimpleNamespace( - typed_command_prefix="!" if platform in {Platform.MATRIX, Platform.SLACK} else "/" + typed_command_prefix="!" + if platform in {Platform.MATRIX, Platform.SLACK} + else "/" ) } return runner @@ -466,57 +477,68 @@ def mutate(self, **kwargs): service = _FakeService(db) rooms = list_messaging_rooms(service) - assert [(room["name"], room["_room_mode"], room["member_count"]) for room in rooms] == [ - ("Release planning", "remote", 2) - ] - assert "⚪ **1. Release planning** · connected · 2 Bots" in format_room_list(service) + assert [ + (room["name"], room["_room_mode"], room["member_count"]) for room in rooms + ] == [("Release planning", "remote", 2)] + assert "⚪ **1. Release planning** · connected · 2 Bots" in format_room_list( + service + ) detail = format_room_detail(service, rooms[0]) assert "💬 **Release planning**" in detail assert "🟡 work queued or running" in detail assert "• **Reviewer:** Ready." in detail assert "A" * 43 not in repr(rooms) - assert send_to_room( - service, - rooms[0], - _event("/group 1 send hello", message_id="remote-send"), - "hello", - ) == "Queued in Release planning." - assert stop_room( - service, - rooms[0], - _event("/group 1 stop", message_id="remote-stop"), - ) == "Stop requested for Release planning. Active work will stop safely." - assert retry_room( - service, - rooms[0], - _event("/group 1 retry", message_id="remote-retry"), - ) == "Retry checked for Release planning (1 task)." + assert ( + send_to_room( + service, + rooms[0], + _event("/group 1 send hello", message_id="remote-send"), + "hello", + ) + == "Queued in Release planning." + ) + assert ( + stop_room( + service, + rooms[0], + _event("/group 1 stop", message_id="remote-stop"), + ) + == "Stop requested for Release planning. Active work will stop safely." + ) + assert ( + retry_room( + service, + rooms[0], + _event("/group 1 retry", message_id="remote-retry"), + ) + == "Retry checked for Release planning (1 task)." + ) assert [call["action"] for call in calls] == ["send", "stop", "retry"] assert calls[0]["actor_display_name"] == "Display Name via Signal" -def test_legacy_projection_uses_the_same_name_identity_as_new_desktop(tmp_path, monkeypatch): +def test_legacy_projection_uses_the_same_name_identity_as_new_desktop( + tmp_path, monkeypatch +): import yaml home = tmp_path / "hermes" home.mkdir(parents=True) (home / "profile.yaml").write_text( - yaml.safe_dump( - { - "ui_meta": { - "hermes-bots-groups": { - "version": 2, - "rooms": { - "Legacy planning": { - "name": "Legacy planning", - "members": [{"name": "default"}], - "log": [], - } - }, - } + yaml.safe_dump({ + "ui_meta": { + "hermes-bots-groups": { + "version": 2, + "rooms": { + "Legacy planning": { + "name": "Legacy planning", + "members": [{"name": "default"}], + "log": [], + } + }, } } - ), + }), encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(home)) @@ -526,7 +548,9 @@ def test_legacy_projection_uses_the_same_name_identity_as_new_desktop(tmp_path, assert room["room_id"] == "name:Legacy planning" -def test_more_than_128_classic_rooms_list_without_disabling_controls(tmp_path, monkeypatch): +def test_more_than_128_classic_rooms_list_without_disabling_controls( + tmp_path, monkeypatch +): import yaml home = tmp_path / "hermes" @@ -541,9 +565,9 @@ def test_more_than_128_classic_rooms_list_without_disabling_controls(tmp_path, m for index in range(260) } (home / "profile.yaml").write_text( - yaml.safe_dump( - {"ui_meta": {"hermes-bots-groups": {"version": 3, "rooms": rooms}}} - ), + yaml.safe_dump({ + "ui_meta": {"hermes-bots-groups": {"version": 3, "rooms": rooms}} + }), encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(home)) @@ -554,7 +578,9 @@ def test_more_than_128_classic_rooms_list_without_disabling_controls(tmp_path, m assert len({room["messaging_ref"] for room in listed}) == 260 -def test_malformed_projected_room_does_not_hide_healthy_group_chats(tmp_path, monkeypatch): +def test_malformed_projected_room_does_not_hide_healthy_group_chats( + tmp_path, monkeypatch +): import yaml home = tmp_path / "hermes" @@ -597,14 +623,18 @@ def test_classic_room_send_and_stop_wait_for_desktop(tmp_path, monkeypatch): "hello", ) - assert sent == "Saved for Desktop planning. Open or update Hermes Desktop to continue." + assert ( + sent == "Saved for Desktop planning. Open or update Hermes Desktop to continue." + ) commands = desktop_room_mailbox.claim_commands( desktop_room_mailbox.default_db_path(), consumer_id="desktop:test", - room_authorities=[{ - "room_id": "classic-room", - "authority_token": "authority:test", - }], + room_authorities=[ + { + "room_id": "classic-room", + "authority_token": "authority:test", + } + ], ) assert [(item["action"], item["payload"]) for item in commands] == [ ( @@ -630,10 +660,12 @@ def test_classic_room_send_and_stop_wait_for_desktop(tmp_path, monkeypatch): stop_commands = desktop_room_mailbox.claim_commands( desktop_room_mailbox.default_db_path(), consumer_id="desktop:test", - room_authorities=[{ - "room_id": "classic-room", - "authority_token": "authority:test", - }], + room_authorities=[ + { + "room_id": "classic-room", + "authority_token": "authority:test", + } + ], actions=["stop"], ) assert [(item["action"], item["payload"]) for item in stop_commands] == [ @@ -647,7 +679,9 @@ def test_classic_room_send_and_stop_wait_for_desktop(tmp_path, monkeypatch): ] -def test_legacy_desktop_room_control_requests_one_current_desktop_open(tmp_path, monkeypatch): +def test_legacy_desktop_room_control_requests_one_current_desktop_open( + tmp_path, monkeypatch +): import yaml home = tmp_path / "hermes" @@ -688,10 +722,12 @@ def test_classic_room_detail_surfaces_failed_command_recovery(tmp_path, monkeypa claimed = desktop_room_mailbox.claim_commands( db, consumer_id="desktop:test", - room_authorities=[{ - "room_id": "classic-room", - "authority_token": "authority:test", - }], + room_authorities=[ + { + "room_id": "classic-room", + "authority_token": "authority:test", + } + ], )[0] desktop_room_mailbox.complete_command( db, @@ -733,15 +769,20 @@ def test_classic_retry_requeues_all_expired_commands_and_replays_receipt( ) now[0] += 1 now[0] += desktop_room_mailbox.PENDING_TTL_SECONDS + 1 - assert desktop_room_mailbox.claim_commands( - db, - consumer_id="desktop:test", - room_authorities=[{ - "room_id": "classic-room", - "authority_token": "authority:test", - }], - clock=lambda: now[0], - ) == [] + assert ( + desktop_room_mailbox.claim_commands( + db, + consumer_id="desktop:test", + room_authorities=[ + { + "room_id": "classic-room", + "authority_token": "authority:test", + } + ], + clock=lambda: now[0], + ) + == [] + ) service = _FakeService(db) room = list_messaging_rooms(service)[0] @@ -884,8 +925,7 @@ def test_room_numbers_stay_stable_and_are_not_reused_after_disband(tmp_path): db, first, second = _seed_rooms(tmp_path) service = _FakeService(db) initial = { - room["room_id"]: room["messaging_ref"] - for room in list_messaging_rooms(service) + room["room_id"]: room["messaging_ref"] for room in list_messaging_rooms(service) } hosted_rooms.disband_room( @@ -1059,9 +1099,7 @@ async def test_bare_group_uses_native_picker_and_selection_refreshes_detail( runner._thread_metadata_for_source = lambda source, anchor=None: {} runner._reply_anchor_for_event = lambda event: None - result = await runner._handle_rooms_command( - _event("/group", platform=platform) - ) + result = await runner._handle_rooms_command(_event("/group", platform=platform)) assert result is None assert len(adapter.calls) == 1 @@ -1230,7 +1268,9 @@ def fail_detail(*_args, **_kwargs): @pytest.mark.asyncio -async def test_group_list_pages_keep_every_stable_number_reachable(tmp_path, monkeypatch): +async def test_group_list_pages_keep_every_stable_number_reachable( + tmp_path, monkeypatch +): db, _, _ = _seed_rooms(tmp_path) for index in range(3, 11): hosted_rooms.create_room( @@ -1266,9 +1306,7 @@ async def test_mutating_room_commands_require_the_stable_number(tmp_path, monkey "gateway.hosted_room_messaging.current_room_backend", lambda: service ) - result = await _runner()._handle_room_command( - _event("/group stop Release room") - ) + result = await _runner()._handle_room_command(_event("/group stop Release room")) assert result == ( "Use `/group send `, `/group retry`, or " @@ -1676,13 +1714,14 @@ def test_group_detail_only_offers_actions_that_match_current_state(tmp_path): "running": False, "working": False, "blocked": False, - "peer_routes": [ - {"member_id": "remote", "status": "needs_reauthorization"} - ], + "peer_routes": [{"member_id": "remote", "status": "needs_reauthorization"}], } - assert MessagingRoomBackend(db_path=db, service=service).status( - "release-room" - )["blocked"] is True + assert ( + MessagingRoomBackend(db_path=db, service=service).status("release-room")[ + "blocked" + ] + is True + ) classic = { "room_id": "classic-room", diff --git a/tests/gateway/test_hosted_room_messaging_approvals.py b/tests/gateway/test_hosted_room_messaging_approvals.py index 9a75dc619e322..6b9ead16dc3e8 100644 --- a/tests/gateway/test_hosted_room_messaging_approvals.py +++ b/tests/gateway/test_hosted_room_messaging_approvals.py @@ -59,19 +59,25 @@ def test_pending_approval_is_bounded_and_cleared_exactly(tmp_path): "command": "pytest -q tests/focused", "choices": ["once", "deny"], } - assert approvals.clear_pending_approval( - db, - room_id="room-1", - member_id="member-1", - request_id="other-request", - ) == 0 + assert ( + approvals.clear_pending_approval( + db, + room_id="room-1", + member_id="member-1", + request_id="other-request", + ) + == 0 + ) assert len(approvals.list_pending_approvals(db, room_id="room-1")) == 1 - assert approvals.clear_pending_approval( - db, - room_id="room-1", - member_id="member-1", - request_id="request-1", - ) == 1 + assert ( + approvals.clear_pending_approval( + db, + room_id="room-1", + member_id="member-1", + request_id="request-1", + ) + == 1 + ) def test_existing_approval_table_migrates_observer_generation(tmp_path): @@ -100,9 +106,10 @@ def test_existing_approval_table_migrates_observer_generation(tmp_path): pending = _pending(db) assert pending["observer_generation"] == "legacy" - assert approvals.list_pending_approvals(db, room_id="room-1")[0][ - "observer_generation" - ] == "legacy" + assert ( + approvals.list_pending_approvals(db, room_id="room-1")[0]["observer_generation"] + == "legacy" + ) def test_existing_approval_table_migrates_concurrently(tmp_path): @@ -399,10 +406,13 @@ def test_stale_pending_approvals_and_commands_expire(tmp_path, monkeypatch): assert approvals.list_pending_approvals(db, room_id="room-1") == [] assert approvals.list_pending_approval_commands(db, room_id="room-1") == [] - assert approvals.approval_command( - db, - command_id="approval-command-1", - ) is None + assert ( + approvals.approval_command( + db, + command_id="approval-command-1", + ) + is None + ) approvals.persist_pending_approval( db, @@ -410,10 +420,13 @@ def test_stale_pending_approvals_and_commands_expire(tmp_path, monkeypatch): member_id="member-2", action=_action(task_id="task-2", request_id="request-2"), ) - assert approvals.approval_command( - db, - command_id="approval-command-1", - ) is None + assert ( + approvals.approval_command( + db, + command_id="approval-command-1", + ) + is None + ) def test_pending_approval_journal_has_a_per_room_cap(tmp_path, monkeypatch): @@ -477,14 +490,20 @@ def test_completed_receipts_do_not_consume_the_pending_cap(tmp_path, monkeypatch result="Approved once.", ) - assert approvals.approval_command( - db, - command_id="approval-command-1", - )["state"] == "completed" - assert approvals.approval_command( - db, - command_id="approval-command-3", - )["state"] == "completed" + assert ( + approvals.approval_command( + db, + command_id="approval-command-1", + )["state"] + == "completed" + ) + assert ( + approvals.approval_command( + db, + command_id="approval-command-3", + )["state"] + == "completed" + ) def test_multiple_approvals_require_an_explicit_number(tmp_path): @@ -500,14 +519,20 @@ def test_multiple_approvals_require_an_explicit_number(tmp_path): with pytest.raises(approvals.MessagingApprovalError, match="Choose"): approvals.select_pending_approval(pending) - assert approvals.select_pending_approval( - pending, - approvals.approval_reference(first), - )[1] == first - assert approvals.select_pending_approval( - pending, - approvals.approval_reference(second), - )[1] == second + assert ( + approvals.select_pending_approval( + pending, + approvals.approval_reference(first), + )[1] + == first + ) + assert ( + approvals.select_pending_approval( + pending, + approvals.approval_reference(second), + )[1] + == second + ) def test_approval_code_does_not_retarget_after_list_reordering(tmp_path): @@ -538,10 +563,13 @@ def test_single_text_approval_also_requires_its_stable_code(tmp_path): with pytest.raises(approvals.MessagingApprovalError, match="approval code"): approvals.select_pending_approval([pending]) - assert approvals.select_pending_approval( - [pending], - approvals.approval_reference(pending), - )[1] == pending + assert ( + approvals.select_pending_approval( + [pending], + approvals.approval_reference(pending), + )[1] + == pending + ) def test_desktop_hosted_approval_names_the_supported_surface(tmp_path): @@ -611,13 +639,13 @@ def test_approval_display_neutralizes_markup_shaped_bot_and_command_text(tmp_pat ) room = { "room_id": "room-1", - "members": [ - {"member_id": "member-1", "display_name": "[Admin](url) @all"} - ], + "members": [{"member_id": "member-1", "display_name": "[Admin](url) @all"}], } rendered = approvals.format_pending_approvals( - type("Service", (), {"status": lambda *_args: {"pending_actions": [pending]}})(), + type( + "Service", (), {"status": lambda *_args: {"pending_actions": [pending]}} + )(), room, room_reference="1", ) @@ -658,7 +686,9 @@ def test_native_picker_title_keeps_the_complete_bounded_action(tmp_path): assert action in title rendered = approvals.format_pending_approvals( - type("Service", (), {"status": lambda *_args: {"pending_actions": [pending]}})(), + type( + "Service", (), {"status": lambda *_args: {"pending_actions": [pending]}} + )(), {"room_id": "room-1", "members": []}, room_reference="1", ) diff --git a/tests/gateway/test_hosted_room_messaging_security.py b/tests/gateway/test_hosted_room_messaging_security.py index c2940aa8dab0c..d05ba9d3c6596 100644 --- a/tests/gateway/test_hosted_room_messaging_security.py +++ b/tests/gateway/test_hosted_room_messaging_security.py @@ -60,8 +60,7 @@ async def test_dm_label_without_one_to_one_proof_cannot_control_group_chats( ) assert result == ( - "Group Chat controls are private. Use your authorized one-to-one " - "Hermes chat." + "Group Chat controls are private. Use your authorized one-to-one Hermes chat." ) @@ -70,7 +69,9 @@ async def test_dm_label_without_one_to_one_proof_cannot_control_group_chats( "platform", [Platform.WHATSAPP_CLOUD, Platform.EMAIL, Platform.SMS], ) -async def test_native_distinct_dm_proves_private_owner_surface(tmp_path, monkeypatch, platform): +async def test_native_distinct_dm_proves_private_owner_surface( + tmp_path, monkeypatch, platform +): service = _FakeService(tmp_path / "state.db") monkeypatch.setattr( "gateway.hosted_room_messaging.current_room_backend", lambda: service @@ -102,7 +103,9 @@ async def test_edited_message_cannot_start_group_chat_work(tmp_path, monkeypatch result = await _runner()._handle_rooms_command(event) - assert result == "Edited messages can’t run Group Chat commands. Send a new message." + assert ( + result == "Edited messages can’t run Group Chat commands. Send a new message." + ) assert service.sent == [] @@ -280,13 +283,11 @@ def test_relay_and_session_roundtrip_preserve_bot_provenance(): assert SessionSource.from_dict(source.to_dict()).is_bot is True wire_source = source.to_dict() wire_source["message_is_edit"] = False - relayed = _event_from_wire( - { - "text": "/group", - "message_type": "command", - "source": wire_source, - } - ) + relayed = _event_from_wire({ + "text": "/group", + "message_type": "command", + "source": wire_source, + }) assert relayed.source.is_bot is True assert relay_provenance_is_unknown(relayed) is False @@ -294,18 +295,16 @@ def test_relay_and_session_roundtrip_preserve_bot_provenance(): def test_legacy_relay_without_author_classification_fails_closed(): from gateway.relay.ws_transport import _event_from_wire - relayed = _event_from_wire( - { - "text": "/group", - "message_type": "command", - "source": { - "platform": "discord", - "chat_id": "chat-1", - "chat_type": "dm", - "user_id": "user-1", - }, - } - ) + relayed = _event_from_wire({ + "text": "/group", + "message_type": "command", + "source": { + "platform": "discord", + "chat_id": "chat-1", + "chat_type": "dm", + "user_id": "user-1", + }, + }) assert relay_provenance_is_unknown(relayed) is True @@ -337,13 +336,11 @@ def test_authenticated_relay_preserves_one_to_one_privacy_proof( } if verified is not None: source["one_to_one_verified"] = verified - relayed = _event_from_wire( - { - "text": "/group", - "message_type": "command", - "source": source, - } - ) + relayed = _event_from_wire({ + "text": "/group", + "message_type": "command", + "source": source, + }) assert relayed.source.is_one_to_one is expected assert relay_provenance_is_unknown(relayed) is False @@ -362,22 +359,22 @@ async def test_classified_relay_dm_with_explicit_admin_can_control_group_chats( "gateway.hosted_room_messaging.current_room_backend", lambda: service, ) - event = _event_from_wire( - { - "text": "/group list", - "message_type": "command", - "source": { - "platform": "signal", - "chat_id": "chat-signal", - "chat_type": "dm", - "user_id": "user-1", - "is_bot": False, - "message_is_edit": False, - }, - } - ) + event = _event_from_wire({ + "text": "/group list", + "message_type": "command", + "source": { + "platform": "signal", + "chat_id": "chat-signal", + "chat_type": "dm", + "user_id": "user-1", + "is_bot": False, + "message_is_edit": False, + }, + }) - result = await _runner(extra={"allow_admin_from": ["user-1"]})._handle_rooms_command(event) + result = await _runner( + extra={"allow_admin_from": ["user-1"]} + )._handle_rooms_command(event) assert result.startswith("👥 **Group Chats**") @@ -422,11 +419,12 @@ async def test_even_an_admin_cannot_expose_room_history_in_a_shared_chat( ) event = _event("/group list", user_id="admin", chat_type="group") - result = await _runner(extra={"group_allow_admin_from": ["admin"]})._handle_rooms_command(event) + result = await _runner( + extra={"group_allow_admin_from": ["admin"]} + )._handle_rooms_command(event) assert result == ( - "Group Chat controls are private. Use your authorized one-to-one " - "Hermes chat." + "Group Chat controls are private. Use your authorized one-to-one Hermes chat." ) assert "Release room" not in result @@ -444,9 +442,7 @@ async def test_room_history_and_mutation_require_an_explicit_admin( runner._is_user_authorized_for_source = lambda _source: True denial = "This chat can’t control Group Chats" assert denial in await runner._handle_rooms_command(_event("/group")) - assert denial in await runner._handle_room_command( - _event("/group 1 send hello") - ) + assert denial in await runner._handle_room_command(_event("/group 1 send hello")) assert service.sent == [] @@ -615,11 +611,7 @@ def fake_profile_scope(path): def fake_auth_env(name, default=""): if name == "SIGNAL_ALLOWED_USERS": - return ( - "user-1,user-2" - if active_scope["name"] == "transport" - else "user-1" - ) + return "user-1,user-2" if active_scope["name"] == "transport" else "user-1" return default monkeypatch.setattr(gateway_run, "_profile_runtime_scope", fake_profile_scope) @@ -641,9 +633,7 @@ def fake_auth_env(name, default=""): @pytest.mark.asyncio -async def test_secondary_adapter_allowlist_owns_the_owner_census( - tmp_path, monkeypatch -): +async def test_secondary_adapter_allowlist_owns_the_owner_census(tmp_path, monkeypatch): db, _, _ = _seed_rooms(tmp_path) service = _FakeService(db) monkeypatch.setattr( @@ -740,7 +730,9 @@ async def test_busy_dispatch_runs_room_control_without_touching_main_agent(): @pytest.mark.asyncio -async def test_real_service_persists_server_owned_messaging_actor(tmp_path, monkeypatch): +async def test_real_service_persists_server_owned_messaging_actor( + tmp_path, monkeypatch +): service = _TestHostedRoomService(tmp_path / "state.db") service.create_room( room_id="release-room", @@ -809,16 +801,18 @@ def test_cross_process_store_wakes_owner_without_desktop_transport(tmp_path): service.start() try: _wait_for( - lambda: sum( - row["kind"] == "message.member" - for row in hosted_rooms.read_events( - service.db_path, - room_id="release-room", - since_seq=0, - limit=40, - )["events"] + lambda: ( + sum( + row["kind"] == "message.member" + for row in hosted_rooms.read_events( + service.db_path, + room_id="release-room", + since_seq=0, + limit=40, + )["events"] + ) + == 2 ) - == 2 ) finally: assert service.stop(timeout=1.0) @@ -917,9 +911,7 @@ def test_cross_process_send_is_idempotent_on_transport_redelivery( ) assert first["seq"] == second["seq"] == 1 assert second["idempotent"] is True - delta = hosted_rooms.read_events( - db, room_id=room["room_id"], since_seq=0, limit=20 - ) + delta = hosted_rooms.read_events(db, room_id=room["room_id"], since_seq=0, limit=20) assert len(delta["events"]) == 1 @@ -955,10 +947,12 @@ def test_cross_process_stop_is_acknowledged_by_owner_for_running_work(tmp_path): ) assert messaging_process.stop_room("release-room", cancel_id="stop-1") == 1 _wait_for( - lambda: hosted_room_driver.list_tasks( - service.db_path, room_id="release-room" - )[0]["status"] - == "cancelled" + lambda: ( + hosted_room_driver.list_tasks(service.db_path, room_id="release-room")[ + 0 + ]["status"] + == "cancelled" + ) ) finally: assert service.stop(timeout=1.0) diff --git a/tests/gateway/test_slash_dispatch_logging.py b/tests/gateway/test_slash_dispatch_logging.py new file mode 100644 index 0000000000000..7c4f872f002dc --- /dev/null +++ b/tests/gateway/test_slash_dispatch_logging.py @@ -0,0 +1,50 @@ +"""Compatibility checks for extracted gateway slash dispatch.""" + +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.platforms.base import MessageEvent +from gateway.slash_dispatch import GatewaySlashDispatchMixin + + +class _FailingPicker: + async def send_choice_picker(self, **kwargs): + raise RuntimeError("picker unavailable") + + +class _Runner(GatewaySlashDispatchMixin): + def __init__(self): + self.adapter = _FailingPicker() + + def _adapter_for_source(self, source): + return self.adapter + + def _thread_metadata_for_source(self, source, anchor=None): + return {} + + def _reply_anchor_for_event(self, event): + return None + + +@pytest.mark.asyncio +async def test_picker_failure_keeps_gateway_logger_identity(caplog): + source = SimpleNamespace(chat_id="chat", user_id="user") + event = MessageEvent(text="/group", source=source) + + with caplog.at_level(logging.WARNING, logger="gateway.run"): + sent = await _Runner()._try_send_choice_picker( + event, + "session-key", + "Choose", + [], + AsyncMock(), + ) + + assert sent is False + assert any( + record.name == "gateway.run" and "falling back to text" in record.message + for record in caplog.records + ) diff --git a/tests/tui_gateway/test_hosted_room_messaging_approvals.py b/tests/tui_gateway/test_hosted_room_messaging_approvals.py index 433f541139d34..8134b4067aa7e 100644 --- a/tests/tui_gateway/test_hosted_room_messaging_approvals.py +++ b/tests/tui_gateway/test_hosted_room_messaging_approvals.py @@ -118,9 +118,10 @@ def approve(self, **_kwargs): ) assert service.status("room-1")["pending_actions"][0]["request_id"] == "request-1" - assert approvals.list_pending_approvals(db, room_id="room-1")[0][ - "request_id" - ] == "request-1" + assert ( + approvals.list_pending_approvals(db, room_id="room-1")[0]["request_id"] + == "request-1" + ) def test_approval_is_fenced_to_the_authority_epoch(tmp_path): @@ -160,7 +161,9 @@ def test_approval_is_fenced_to_the_authority_epoch(tmp_path): event_id="authority-transfer-1", ) - with pytest.raises(approvals.MessagingApprovalTerminalError, match="authority changed"): + with pytest.raises( + approvals.MessagingApprovalTerminalError, match="authority changed" + ): service.approve_room_task( "room-1", member_id="ops", @@ -315,9 +318,10 @@ def test_old_epoch_empty_callback_cannot_clear_newer_approval(tmp_path): assert service.status("room-1")["pending_actions"][0]["request_id"] == ( "request-new" ) - assert approvals.list_pending_approvals(db, room_id="room-1")[0][ - "request_id" - ] == "request-new" + assert ( + approvals.list_pending_approvals(db, room_id="room-1")[0]["request_id"] + == "request-new" + ) def test_old_process_empty_callback_cannot_clear_replacement_observation(tmp_path): @@ -432,9 +436,10 @@ def test_old_process_empty_callback_cannot_clear_replacement_observation(tmp_pat assert service._pending_actions[("room-1", "ops")]["observer_generation"] == ( "worker-new" ) - assert approvals.list_pending_approvals(db, room_id="room-1")[0][ - "observer_generation" - ] == "worker-new" + assert ( + approvals.list_pending_approvals(db, room_id="room-1")[0]["observer_generation"] + == "worker-new" + ) def test_new_worker_clear_retires_hydrated_old_observation(tmp_path): @@ -617,9 +622,10 @@ def persist_once(*args, **kwargs): service._set_pending_action("room-1", "ops", action) assert len(calls) == 2 assert service.status("room-1")["pending_actions"][0]["request_id"] == "request-1" - assert approvals.list_pending_approvals(db, room_id="room-1")[0][ - "request_id" - ] == "request-1" + assert ( + approvals.list_pending_approvals(db, room_id="room-1")[0]["request_id"] + == "request-1" + ) def test_missing_room_retires_stale_approval_without_contacting_target(tmp_path): @@ -788,12 +794,8 @@ def test_stale_local_approval_cannot_resolve_replacement_request(tmp_path): "session_id": "local-session", "approval": {"choices": ["once", "deny"]}, } - service._set_pending_action( - "room-1", "ops", {**action, "request_id": "approval-A"} - ) - service._set_pending_action( - "room-1", "ops", {**action, "request_id": "approval-B"} - ) + service._set_pending_action("room-1", "ops", {**action, "request_id": "approval-A"}) + service._set_pending_action("room-1", "ops", {**action, "request_id": "approval-B"}) with pytest.raises(RuntimeError, match="no longer pending"): service.approve_room_task( diff --git a/tests/tui_gateway/test_hosted_room_messaging_retry.py b/tests/tui_gateway/test_hosted_room_messaging_retry.py index 0c98fe6b479e8..b8309cfed8cee 100644 --- a/tests/tui_gateway/test_hosted_room_messaging_retry.py +++ b/tests/tui_gateway/test_hosted_room_messaging_retry.py @@ -137,12 +137,15 @@ def complete_after_takeover(*args, **kwargs): if mode == "lease_takeover": with pytest.raises(driver.StaleLeaseError): service.runtime._process_room(service.bindings()[0]) - assert len( - hosted_room_controls.load_pending_control_retries( - db, - room_id="room-1", + assert ( + len( + hosted_room_controls.load_pending_control_retries( + db, + room_id="room-1", + ) ) - ) == 1 + == 1 + ) service.runtime.process_generation = "takeover-worker" service.runtime._leases.clear() service.runtime._process_room(service.bindings()[0]) @@ -159,9 +162,7 @@ def complete_after_takeover(*args, **kwargs): ) assert completed.result == {"action": "retry", "processed": 1} assert driver.get_task(db, task["identity"])["status"] == ( - "cancelled" - if mode in {"stopped", "already_cancelled"} - else "settled" + "cancelled" if mode in {"stopped", "already_cancelled"} else "settled" ) if mode not in {"stopped", "already_cancelled"}: assert driver.retry_receipt_exists( @@ -175,8 +176,7 @@ def complete_after_takeover(*args, **kwargs): ) if mode in {"stopped", "already_cancelled"}: assert not any( - event["kind"] == "message.member" - for event in service._events("room-1") + event["kind"] == "message.member" for event in service._events("room-1") ) @@ -283,9 +283,10 @@ def complete_after_lost_response(*args, **kwargs): retry_id=retry_id, ) assert driver.get_task(db, task["identity"])["status"] == "queued" - assert len( - hosted_room_controls.load_pending_control_retries(db, room_id="room-1") - ) == 1 + assert ( + len(hosted_room_controls.load_pending_control_retries(db, room_id="room-1")) + == 1 + ) second_attempt = driver.start_task( db, @@ -319,7 +320,10 @@ def complete_after_lost_response(*args, **kwargs): service._apply_pending_control_retries(service.bindings()[0], next_lease) assert driver.get_task(db, task["identity"])["status"] == "deferred" - assert hosted_room_controls.load_pending_control_retries( - db, - room_id="room-1", - ) == () + assert ( + hosted_room_controls.load_pending_control_retries( + db, + room_id="room-1", + ) + == () + ) diff --git a/tui_gateway/change_signatures.py b/tui_gateway/change_signatures.py new file mode 100644 index 0000000000000..b5c9a4f69ae15 --- /dev/null +++ b/tui_gateway/change_signatures.py @@ -0,0 +1,67 @@ +"""Filesystem signatures used by the TUI gateway change watcher.""" + +from pathlib import Path + + +def cron_signature(home: Path) -> int | None: + """Return the cron ledger mtime when present.""" + try: + return (home / "cron" / "jobs.json").stat().st_mtime_ns + except OSError: + return None + + +def sessions_signature(home: Path) -> int | None: + """Return the newest SQLite database or WAL mtime.""" + signature = None + for name in ("state.db", "state.db-wal"): + try: + mtime = (home / name).stat().st_mtime_ns + except OSError: + continue + signature = mtime if signature is None else max(signature, mtime) + return signature + + +def platforms_signature(home: Path) -> int | None: + """Return the persisted gateway-state mtime when present.""" + try: + return (home / "gateway_state.json").stat().st_mtime_ns + except OSError: + return None + + +def pairing_signature(home: Path) -> int | None: + """Return the newest pending/approved pairing-ledger mtime.""" + signature = None + roots = [home / "pairing", home / "platforms" / "pairing"] + try: + for profile_dir in (home / "profiles").iterdir(): + roots.append(profile_dir / "pairing") + roots.append(profile_dir / "platforms" / "pairing") + except OSError: + pass + + for root in roots: + try: + entries = list(root.iterdir()) + except OSError: + continue + for entry in entries: + if not entry.name.endswith(("-pending.json", "-approved.json")): + continue + try: + mtime = entry.stat().st_mtime_ns + except OSError: + continue + signature = mtime if signature is None else max(signature, mtime) + return signature + + +def desktop_room_mailbox_signature(home: Path) -> int | None: + """Return the cross-process Desktop-room command mailbox mtime.""" + root = home.parent.parent if home.parent.name == "profiles" else home + try: + return (root / "desktop_room_mailbox.pending").stat().st_mtime_ns + except OSError: + return None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 3831c4b48c0bd..b19e7454bd23c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -140,6 +140,13 @@ def _thread_panic_hook(args): pass from tui_gateway.render import make_stream_renderer, render_diff, render_message +from tui_gateway.change_signatures import ( + cron_signature, + desktop_room_mailbox_signature, + pairing_signature, + platforms_signature, + sessions_signature, +) _sessions: dict[str, dict] = {} _methods: dict[str, callable] = {} @@ -5105,10 +5112,7 @@ def _pet_changed_payload() -> dict: def _cron_sig(): """mtime of the profile's cron/jobs.json — moves on create/edit/pause/ remove AND on scheduler tick bookkeeping (last_run/next_run).""" - try: - return (_watcher_home() / "cron" / "jobs.json").stat().st_mtime_ns - except OSError: - return None + return cron_signature(_watcher_home()) def _sessions_sig(): @@ -5116,25 +5120,14 @@ def _sessions_sig(): signal. Messaging-gateway turns and cron runs are written by OTHER processes that never touch this gateway's transports; the shared SQLite file is the one thing they all move (#58671).""" - home = _watcher_home() - sig = None - for name in ("state.db", "state.db-wal"): - try: - mtime = (home / name).stat().st_mtime_ns - except OSError: - continue - sig = mtime if sig is None else max(sig, mtime) - return sig + return sessions_signature(_watcher_home()) def _platforms_sig(): """mtime of gateway_state.json — the messaging gateway process persists platform connect/disconnect/health there, so its movement is the "connection status changed" signal for the Messaging page.""" - try: - return (_watcher_home() / "gateway_state.json").stat().st_mtime_ns - except OSError: - return None + return platforms_signature(_watcher_home()) def _pairing_sig(): @@ -5146,35 +5139,7 @@ def _pairing_sig(): for this: it tracks connect/disconnect/health, and a pairing request moves nothing in gateway_state.json. """ - home = _watcher_home() - sig = None - # Global store (legacy `pairing/` and consolidated `platforms/pairing/`) - # plus every named profile's own — the Messaging page can be scoped to any - # of them, and a request landing in a profile store must still tick. - roots = [home / "pairing", home / "platforms" / "pairing"] - try: - for profile_dir in (home / "profiles").iterdir(): - roots.append(profile_dir / "pairing") - roots.append(profile_dir / "platforms" / "pairing") - except OSError: - pass - - for root in roots: - try: - entries = list(root.iterdir()) - except OSError: - continue - for entry in entries: - # Only the pending/approved ledgers — _rate_limits.json moves on - # every unauthorized DM, including ones that produce no new row. - if not entry.name.endswith(("-pending.json", "-approved.json")): - continue - try: - mtime = entry.stat().st_mtime_ns - except OSError: - continue - sig = mtime if sig is None else max(sig, mtime) - return sig + return pairing_signature(_watcher_home()) # Newest outbox-envelope mtime the watcher has EVER seen (monotone). A drain @@ -5216,12 +5181,7 @@ def _bot_relay_outbox_sig(): def _desktop_room_mailbox_sig(): """mtime of commands written by a messaging process for Desktop rooms.""" - home = _watcher_home() - root = home.parent.parent if home.parent.name == "profiles" else home - try: - return (root / "desktop_room_mailbox.pending").stat().st_mtime_ns - except OSError: - return None + return desktop_room_mailbox_signature(_watcher_home()) # Watched change signals: event → (check interval, signature fn, payload fn). From 59ad855f0e5d70975dce19040a4f42459e0eb440 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:58:35 +0200 Subject: [PATCH 08/16] fix(bot-mode): harden Group Chat messaging controls --- .../desktop-room-command-client.ts | 1 + .../desktop-room-command-runtime.test.ts | 124 ++++++++++- .../desktop-room-command-runtime.ts | 48 +++- gateway/desktop_room_mailbox.py | 22 +- gateway/group_chat_slash.py | 65 +++++- gateway/hosted_room_controls.py | 31 ++- gateway/hosted_room_messaging.py | 183 +++++++--------- gateway/hosted_room_messaging_approvals.py | 6 + gateway/hosted_room_messaging_retries.py | 205 ++++++++++++++++++ gateway/run.py | 2 +- gateway/slash_commands.py | 26 ++- .../test_api_server_room_controls.py | 19 ++ tests/gateway/test_desktop_room_mailbox.py | 32 +++ tests/gateway/test_hosted_room_controls.py | 68 ++++++ tests/gateway/test_hosted_room_messaging.py | 60 ++++- .../test_hosted_room_messaging_approvals.py | 20 ++ .../test_hosted_room_messaging_retries.py | 92 ++++++++ .../test_hosted_room_messaging_security.py | 191 ++++++++++++++++ tests/tui_gateway/test_hosted_room_service.py | 8 +- .../test_hosted_room_stop_replay.py | 78 +++++++ tui_gateway/hosted_room_service.py | 5 +- 21 files changed, 1156 insertions(+), 130 deletions(-) create mode 100644 gateway/hosted_room_messaging_retries.py create mode 100644 tests/gateway/test_hosted_room_messaging_retries.py create mode 100644 tests/tui_gateway/test_hosted_room_stop_replay.py diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts index e7f66973ab2f1..f7ae324eb2fac 100644 --- a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-client.ts @@ -13,6 +13,7 @@ export interface DesktopRoomDescriptor { export interface DesktopRoomCommand { action?: string + attempts?: number command_id?: string lease_token?: string payload?: Record diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts index 97f1458e4a2a8..063c058284cfb 100644 --- a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.test.ts @@ -379,7 +379,7 @@ describe('classic Group Chat command runtime', () => { { action: 'stop', command_id: 'messaging:stop-earlier', - payload: { target_thread_id: 'thread-old' }, + payload: { target_message_id: 'old-message', target_thread_id: 'thread-old' }, room_id: 'room-1' }, descriptors, @@ -415,4 +415,126 @@ describe('classic Group Chat command runtime', () => { await abandonedExpectation expect(groupRounds.cancelGroupThreadForLeaseLoss).toHaveBeenCalledWith('Planning', members) }) + + it('does not re-drive terminal Stops or stop newer same-thread work', async () => { + const loaded = await loadRuntime() + const stored = new Map() + const members = [{ connectionId: 'gateway-a', name: 'online' }] + + loaded.data.$lastRoster.set(members) + loaded.chat.$groupChats.set({ + Planning: { + log: [ + { + at: 2, + from: { kind: 'user', name: 'You' }, + id: 'new-message', + text: 'Newer work in the same thread', + thread: 'thread-1' + } + ], + members, + roomId: 'room-1', + sessions: {}, + watermarks: {} + } + }) + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + const descriptors = [{ authorityToken: 'authority:test', name: 'Planning', roomId: 'room-1' }] + const context = { + consumerId: 'desktop:test', + request: vi.fn(async () => ({})), + route: { + connectionId: 'gateway-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'default' + }, + signal: null + } + + const terminal = await loaded.runtime.executeDesktopRoomCommand( + { + action: 'stop', + command_id: 'messaging:stop-terminal', + payload: { target_command_id: 'messaging:send-complete' }, + room_id: 'room-1', + target_command_state: 'completed' + }, + descriptors, + context + ) + const stale = await loaded.runtime.executeDesktopRoomCommand( + { + action: 'stop', + command_id: 'messaging:stop-stale', + payload: { target_message_id: 'old-message', target_thread_id: 'thread-1' }, + room_id: 'room-1' + }, + descriptors, + context + ) + + expect(terminal).toEqual({ room_name: 'Planning', stale: true, stopped: false }) + expect(stale).toEqual({ room_name: 'Planning', stale: true, stopped: false }) + expect(groupRounds.stopGroupThread).not.toHaveBeenCalled() + loaded.runtime.stopDesktopRoomCommandRuntime() + }) + + it('bounds repeated classic room execution after the final mailbox claim', async () => { + const loaded = await loadRuntime() + const stored = new Map() + const members = [{ connectionId: 'gateway-a', name: 'online' }] + + loaded.data.$lastRoster.set(members) + loaded.chat.$groupChats.set({ + Planning: { + log: [], + members, + roomId: 'room-1', + sessions: {}, + watermarks: {} + } + }) + groupRounds.sendToGroupChat.mockImplementation(() => { + const room = loaded.chat.$groupChats.get().Planning + loaded.chat.$groupChats.set({ Planning: { ...room, running: false } }) + + return 'thread-rejected' + }) + await loaded.runtime.startDesktopRoomCommandRuntime(scriptedStorage(stored).storage) + + const execution = loaded.runtime.executeDesktopRoomCommand( + { + action: 'send', + attempts: 2, + command_id: 'messaging:send-rejected', + payload: { message: 'Review the plan', recipients: members }, + room_id: 'room-1' + }, + [{ authorityToken: 'authority:test', name: 'Planning', roomId: 'room-1' }], + { + consumerId: 'desktop:test', + request: vi.fn(async () => ({})), + route: { + connectionId: 'gateway-a', + mode: 'remote', + profile: 'default', + targetProfile: 'default' + }, + signal: null + } + ) + const outcome = execution.then( + () => null, + error => error as Error & { retryable?: boolean } + ) + + await vi.advanceTimersByTimeAsync(250) + const error = await outcome + expect(error?.message).toContain('after repeated attempts') + expect(error?.retryable).not.toBe(true) + expect(groupRounds.sendToGroupChat).toHaveBeenCalledTimes(2) + loaded.runtime.stopDesktopRoomCommandRuntime() + }) }) diff --git a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts index fa379720fbb0a..72326280c05ec 100644 --- a/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts +++ b/apps/desktop/src/plugins/hermes-bots/desktop-room-command-runtime.ts @@ -19,6 +19,9 @@ import type { GroupChat, GroupMember, ProfileRoute } from './types' const DESKTOP_ROOM_COMMAND_CONSUMER_KEY = 'desktop-room-command-consumer-v1' const DESKTOP_ROOM_COMMAND_INTERVAL_MS = 60_000 const DESKTOP_ROOM_COMMAND_PUSH_DEBOUNCE_MS = 250 +const DESKTOP_ROOM_DRIVE_ATTEMPTS_PER_CLAIM = 2 +const DESKTOP_ROOM_MAX_CLAIMS = 2 +const DESKTOP_ROOM_DRIVE_RETRY_DELAY_MS = 250 let desktopRoomStorage: null | PluginContext['storage'] = null let desktopRoomCommandConsumerId = '' @@ -339,7 +342,14 @@ export async function executeDesktopRoomCommand( }) try { - while (!desktopRoomCommandDisposed) { + const rawClaimAttempt = Number(command.attempts) + const claimAttempt = Number.isSafeInteger(rawClaimAttempt) && rawClaimAttempt > 0 ? rawClaimAttempt : 1 + + for ( + let driveAttempt = 1; + driveAttempt <= DESKTOP_ROOM_DRIVE_ATTEMPTS_PER_CLAIM && !desktopRoomCommandDisposed; + driveAttempt += 1 + ) { if (localAbort.signal.aborted) { throw retryableDesktopRoomCommand('The command moved to another Desktop.') } @@ -367,6 +377,18 @@ export async function executeDesktopRoomCommand( thread_id: thread } } + + if (driveAttempt < DESKTOP_ROOM_DRIVE_ATTEMPTS_PER_CLAIM) { + await new Promise(resolve => setTimeout(resolve, DESKTOP_ROOM_DRIVE_RETRY_DELAY_MS)) + } + } + + if (!desktopRoomCommandDisposed) { + if (claimAttempt >= DESKTOP_ROOM_MAX_CLAIMS) { + throw new Error('The Group Chat could not finish this command after repeated attempts. Retry it explicitly.') + } + + throw retryableDesktopRoomCommand('The Group Chat command will retry after a short delay.') } } catch (error) { if (localAbort.signal.aborted) { @@ -398,6 +420,7 @@ export async function executeDesktopRoomCommand( const payload = command.payload || {} const targetCommandId = String(payload.target_command_id || '') const targetThreadId = String(payload.target_thread_id || '') + const targetMessageId = String(payload.target_message_id || '') if (room.desktopCommandSettled?.[commandId]) { return { @@ -421,6 +444,17 @@ export async function executeDesktopRoomCommand( } } + if ( + ['completed', 'failed'].includes(String(command.target_command_state || '')) && + active?.commandId !== targetCommandId + ) { + return { + room_name: group, + stale: true, + stopped: false + } + } + for (let attempt = 0; attempt < 40; attempt += 1) { if ($groupChats.get()[group]?.desktopCommandSettled?.[targetCommandId]) { return { @@ -456,7 +490,9 @@ export async function executeDesktopRoomCommand( } } - const latestThread = [...room.log].reverse().find(item => item?.thread)?.thread || null + const latestUser = [...room.log].reverse().find(item => item?.thread && item?.from?.kind === 'user') + const latestThread = latestUser?.thread || null + const latestMessageId = String(latestUser?.eventId || latestUser?.id || '') const stopThread = targetCommandId ? active?.threadId || targetThreadId || latestThread : targetThreadId if (!targetCommandId && targetThreadId && latestThread && targetThreadId !== latestThread) { @@ -467,6 +503,14 @@ export async function executeDesktopRoomCommand( } } + if (!targetCommandId && (!targetMessageId || latestMessageId !== targetMessageId)) { + return { + room_name: group, + stale: true, + stopped: false + } + } + if (!stopThread) { return { room_name: group, diff --git a/gateway/desktop_room_mailbox.py b/gateway/desktop_room_mailbox.py index fe4ecd8438821..2945494161073 100644 --- a/gateway/desktop_room_mailbox.py +++ b/gateway/desktop_room_mailbox.py @@ -22,6 +22,7 @@ MAX_ROOM_IDS = 128 MAX_QUERY_ROOM_IDS = 4096 MAX_COMMANDS_PER_CLAIM = 8 +MAX_AUTOMATIC_SEND_CLAIMS = 2 MAX_PAYLOAD_BYTES = 64 * 1024 # Desktop refreshes classic-room presence on a 60s retained-socket backstop; # push events handle command latency. Keep enough overlap for scheduler jitter @@ -347,6 +348,21 @@ def _expire_stale_state( AND COALESCE(lease_expires_at, 0) <= ?""", (expired_result, now, cutoff, now), ) + exhausted_result = _payload_json({ + "code": "automatic_attempts_exhausted", + "message": "This Group Chat command needs an explicit Retry.", + }) + conn.execute( + """UPDATE desktop_room_commands + SET state='failed', result_json=?, lease_owner=NULL, + lease_token=NULL, lease_expires_at=NULL, updated_at=? + WHERE action='send' AND attempts>=? + AND ( + state='pending' + OR (state='claimed' AND COALESCE(lease_expires_at, 0)<=?) + )""", + (exhausted_result, now, MAX_AUTOMATIC_SEND_CLAIMS, now), + ) conn.execute( """DELETE FROM desktop_room_commands WHERE state IN ('completed', 'failed') AND updated_at <= ?""", @@ -866,7 +882,8 @@ def retry_failed_command( raise DesktopRoomMailboxError("that Group Chat command is not retryable") conn.execute( """UPDATE desktop_room_commands - SET state='pending', lease_token=NULL, lease_owner=NULL, + SET state='pending', attempts=0, + lease_token=NULL, lease_owner=NULL, lease_expires_at=NULL, result_json=NULL, created_at=?, updated_at=? WHERE command_id=? AND state='failed'""", @@ -941,7 +958,8 @@ def retry_failed_commands( continue conn.execute( """UPDATE desktop_room_commands - SET state='pending', lease_token=NULL, lease_owner=NULL, + SET state='pending', attempts=0, + lease_token=NULL, lease_owner=NULL, lease_expires_at=NULL, result_json=NULL, created_at=?, updated_at=? WHERE command_id=? AND state='failed'""", diff --git a/gateway/group_chat_slash.py b/gateway/group_chat_slash.py index 14dcf10d85327..027c176eb8e54 100644 --- a/gateway/group_chat_slash.py +++ b/gateway/group_chat_slash.py @@ -171,7 +171,7 @@ def _can_control_group_chats(self, event: MessageEvent) -> bool: ): return False chat_type = str(getattr(event.source, "chat_type", "") or "").casefold() - if chat_type not in {"", "dm", "direct", "private"}: + if chat_type not in {"dm", "direct", "private"}: return False policy = policy_for_source(self.config, event.source) return policy.enabled and policy.is_admin(event.source.user_id) @@ -187,7 +187,7 @@ def _group_chat_control_denial(event: MessageEvent) -> str: and platform in _NATIVE_DISTINCT_DM_PLATFORMS and chat_type in {"dm", "direct", "private"} ) - if chat_type not in {"", "dm", "direct", "private"} or not proven_private: + if chat_type not in {"dm", "direct", "private"} or not proven_private: return ( "Group Chat controls are private. Use your authorized one-to-one " "Hermes chat." @@ -217,9 +217,13 @@ def _group_chat_rate_limit_denial( bucket_kind = "read" source = event.source + from gateway.hosted_room_messaging import messaging_transport_profile + platform = str(getattr(getattr(source, "platform", None), "value", "") or "") key = ( platform, + str(getattr(source, "profile", None) or "default"), + messaging_transport_profile(event), str(getattr(source, "scope_id", None) or ""), str(getattr(source, "chat_id", None) or ""), str( @@ -265,6 +269,32 @@ def _group_chat_profile(event: MessageEvent) -> str: return str(get_active_profile_name() or "default") + def _can_approve_group_chats(self, event: MessageEvent) -> bool: + """Keep dangerous approvals with the installation owner's main chat.""" + + from gateway.hosted_room_messaging import messaging_transport_profile + + return ( + self._group_chat_profile(event) == "default" + and messaging_transport_profile(event) == "default" + and self._can_control_group_chats(event) + ) + + @staticmethod + def _group_chat_approval_denial() -> str: + return ( + "Approve or deny Bot commands from the installation owner’s " + "authorized one-to-one Hermes chat." + ) + + @staticmethod + def _group_chat_command_args(event: MessageEvent) -> str: + """Return command arguments without rewriting free-form message text.""" + + command_text = str(event.text or "").lstrip() + parts = command_text.split(maxsplit=1) + return parts[1] if len(parts) > 1 else "" + async def _handle_rooms_command(self, event: MessageEvent) -> Optional[str]: """List Bot Group Chats or show one chat's recent activity.""" @@ -299,8 +329,8 @@ async def _handle_rooms_command(self, event: MessageEvent) -> Optional[str]: if not self._can_control_group_chats(event): return self._group_chat_control_denial(event) service = current_room_backend() - rooms_command = f"{self._typed_command_prefix_for(event.source.platform)}group" - query = event.get_command_args().strip() + rooms_command = f"{self._typed_command_prefix_for(event.source)}group" + query = self._group_chat_command_args(event).strip() try: words = query.split() if ( @@ -331,6 +361,8 @@ async def _handle_rooms_command(self, event: MessageEvent) -> Optional[str]: and words[0].isdecimal() and words[1].casefold() == "approvals" ): + if not self._can_approve_group_chats(event): + return self._group_chat_approval_denial() from gateway.hosted_room_messaging_approvals import ( MessagingApprovalError, approval_member_label, @@ -358,8 +390,8 @@ async def _handle_rooms_command(self, event: MessageEvent) -> Optional[str]: session_key = self._session_key_for_source(source) async def _on_approval_selected(_chat_id: str, value: str) -> str: - if not self._can_control_group_chats(event): - return self._group_chat_control_denial(event) + if not self._can_approve_group_chats(event): + return self._group_chat_approval_denial() current_denial = self._group_chat_rate_limit_denial( event, action="approve", @@ -392,6 +424,9 @@ async def _on_approval_selected(_chat_id: str, value: str) -> str: f"{str(value).replace('=', '.')}" ), choice=choice, + installation_owner_authorized=( + self._can_approve_group_chats(event) + ), selection=index, expected_request_id=request_id, ) @@ -545,6 +580,7 @@ async def _on_room_selected(_chat_id: str, value: str) -> str: service, selected, room_command=rooms_command, + show_approvals=self._can_approve_group_chats(event), ) except (RoomControlError, hosted_rooms.HostedRoomError) as exc: return str(exc) @@ -584,6 +620,7 @@ async def _on_room_selected(_chat_id: str, value: str) -> str: service, exact_name, room_command=rooms_command, + show_approvals=self._can_approve_group_chats(event), ) list_parts = query.casefold().split() if not query or (list_parts and list_parts[0] == "list"): @@ -606,6 +643,7 @@ def _detail() -> str: service, room, room_command=rooms_command, + show_approvals=self._can_approve_group_chats(event), ) return await asyncio.to_thread(_detail) @@ -647,12 +685,17 @@ async def _handle_room_command(self, event: MessageEvent) -> str: if not self._can_control_group_chats(event): return self._group_chat_control_denial(event) service = current_room_backend() - rooms_command = f"{self._typed_command_prefix_for(event.source.platform)}group" + rooms_command = f"{self._typed_command_prefix_for(event.source)}group" try: command = parse_room_command( - event.get_command_args(), + self._group_chat_command_args(event), command_root=rooms_command, ) + if command.action in { + "approve", + "deny", + } and not self._can_approve_group_chats(event): + return self._group_chat_approval_denial() denial = self._group_chat_rate_limit_denial( event, action=command.action, @@ -667,9 +710,10 @@ async def _handle_room_command(self, event: MessageEvent) -> str: raise RoomControlError(f"Use `{rooms_command} stop`.") def _mutate() -> str: + profile = self._group_chat_profile(event) rooms = list_messaging_rooms( service, - profile=self._group_chat_profile(event), + profile=profile, ) approval_command_id = f"approval:{messaging_event_id(event)}" approval_receipt = None @@ -741,6 +785,9 @@ def _mutate() -> str: room, command_id=approval_command_id, choice=("once" if command.action == "approve" else "deny"), + installation_owner_authorized=( + self._can_approve_group_chats(event) + ), selection=command.message, ) except MessagingApprovalError as exc: diff --git a/gateway/hosted_room_controls.py b/gateway/hosted_room_controls.py index 92c88e75a0c26..a24e8db9c931f 100644 --- a/gateway/hosted_room_controls.py +++ b/gateway/hosted_room_controls.py @@ -37,6 +37,7 @@ MAX_ROOM_NAME_CHARS = 200 MAX_ROOM_MEMBERS = 64 MAX_CONTROL_COMMANDS = 4096 +CONTROL_COMMAND_RETENTION_SECONDS = 30 * 24 * 60 * 60 ROOM_LIFETIME_EXPIRES_AT = 253_402_300_799.0 _JOURNAL_MODE_LOCK_RETRIES = 5 @@ -327,6 +328,10 @@ def _initialize_schema(conn: sqlite3.Connection) -> None: updated_at REAL NOT NULL )""" ) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_hosted_room_control_commands_retention + ON hosted_room_control_commands(state, updated_at, command_id)""" + ) def _schema_is_current(conn: sqlite3.Connection) -> bool: @@ -1173,13 +1178,37 @@ def begin_control_retry( result=result, idempotent=True, ) + conn.execute( + """DELETE FROM hosted_room_control_commands + WHERE command_id IN ( + SELECT command_id FROM hosted_room_control_commands + WHERE state='completed' AND updated_at= MAX_CONTROL_COMMANDS: + conn.execute( + """DELETE FROM hosted_room_control_commands + WHERE command_id IN ( + SELECT command_id FROM hosted_room_control_commands + WHERE state='completed' + ORDER BY updated_at, command_id + LIMIT ? + )""", + (int(count) - MAX_CONTROL_COMMANDS + 1,), + ) + count = conn.execute( + "SELECT COUNT(*) FROM hosted_room_control_commands" + ).fetchone()[0] if int(count) >= MAX_CONTROL_COMMANDS: raise HostedRoomControlError("stored control command limit reached") if not frozen: - raise HostedRoomControlError("retry task_ids must contain 1-8 tasks") + raise HostedRoomControlError("This Group Chat has no failed work to retry.") conn.execute( """INSERT INTO hosted_room_control_commands ( command_id, room_id, member_id, action, task_ids_json, diff --git a/gateway/hosted_room_messaging.py b/gateway/hosted_room_messaging.py index fc9a19348997b..4196ca3dea52c 100644 --- a/gateway/hosted_room_messaging.py +++ b/gateway/hosted_room_messaging.py @@ -354,17 +354,21 @@ def _frozen_desktop_recipients(room: Mapping[str, Any]) -> list[dict[str, Any]]: return recipients -def _latest_projected_thread(room: Mapping[str, Any]) -> str: +def _latest_projected_stop_target(room: Mapping[str, Any]) -> tuple[str, str]: raw_log = room.get("log") if not isinstance(raw_log, list): - return "" + return "", "" for entry in reversed(raw_log): if not isinstance(entry, Mapping): continue + actor = entry.get("from") + if not isinstance(actor, Mapping) or actor.get("kind") != "user": + continue thread = str(entry.get("thread") or "").strip() if thread: - return thread[:128] - return "" + message_id = str(entry.get("eventId") or entry.get("id") or "").strip() + return thread[:128], message_id[:160] + return "", "" class RoomControlError(ValueError): @@ -784,98 +788,27 @@ def _retry_receipt_plan( task_ids: list[str], ) -> tuple[list[str], str | None]: """Freeze one transport delivery to one bounded retry decision.""" + from gateway.hosted_room_messaging_retries import ( + MessagingRetryReceiptError, + retry_receipt_plan, + ) - db_path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(db_path, timeout=10) - conn.row_factory = sqlite3.Row try: - from hermes_state import apply_wal_with_fallback - - apply_wal_with_fallback(conn, db_label="state.db (Group Chat retry receipts)") - conn.execute("BEGIN IMMEDIATE") - conn.execute( - """CREATE TABLE IF NOT EXISTS hosted_room_messaging_retries ( - command_id TEXT PRIMARY KEY, - room_id TEXT NOT NULL, - actor_json TEXT NOT NULL, - task_ids_json TEXT NOT NULL, - state TEXT NOT NULL, - result_text TEXT, - created_at REAL NOT NULL, - updated_at REAL NOT NULL - )""" - ) - encoded_actor = json.dumps( - dict(actor), ensure_ascii=True, sort_keys=True, separators=(",", ":") - ) - existing = conn.execute( - "SELECT * FROM hosted_room_messaging_retries WHERE command_id = ?", - (command_id,), - ).fetchone() - if existing is not None: - if ( - str(existing["room_id"]) != room_id - or str(existing["actor_json"]) != encoded_actor - ): - raise RoomControlError( - "This retry delivery was already used for different Group Chat work." - ) - frozen = [ - str(item) - for item in json.loads(str(existing["task_ids_json"])) - if str(item) - ] - result = ( - str(existing["result_text"]) - if existing["state"] == "completed" and existing["result_text"] - else None - ) - conn.commit() - return frozen, result - if not task_ids: - raise RoomControlError("This Group Chat has no failed work to retry.") - now = time.time() - conn.execute( - """INSERT INTO hosted_room_messaging_retries ( - command_id, room_id, actor_json, task_ids_json, state, - result_text, created_at, updated_at - ) VALUES (?, ?, ?, ?, 'pending', NULL, ?, ?)""", - ( - command_id, - room_id, - encoded_actor, - json.dumps(task_ids, ensure_ascii=True, separators=(",", ":")), - now, - now, - ), + return retry_receipt_plan( + db_path, + command_id=command_id, + room_id=room_id, + actor=actor, + task_ids=task_ids, ) - conn.commit() - return task_ids, None - except Exception: - conn.rollback() - raise - finally: - conn.close() + except MessagingRetryReceiptError as exc: + raise RoomControlError(str(exc)) from exc def _complete_retry_receipt(db_path: Path, *, command_id: str, result: str) -> None: - conn = sqlite3.connect(db_path, timeout=10) - try: - conn.execute("BEGIN IMMEDIATE") - changed = conn.execute( - """UPDATE hosted_room_messaging_retries - SET state='completed', result_text=?, updated_at=? - WHERE command_id=? AND state='pending'""", - (result, time.time(), command_id), - ) - if changed.rowcount not in {0, 1}: - raise RuntimeError("Group Chat retry receipt changed more than once") - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() + from gateway.hosted_room_messaging_retries import complete_retry_receipt + + complete_retry_receipt(db_path, command_id=command_id, result=result) @dataclass(frozen=True) @@ -993,15 +926,18 @@ def stop_room(self, room_id: str, *, cancel_id: str) -> int: raise hosted_rooms.AuthorityConflictError( "This Group Chat moved to another connected device. Open it there and try again." ) - hosted_rooms.request_room_stop( + stop_event = hosted_rooms.request_room_stop( self.db_path, room_id=room_id, cancel_id=cancel_id, expected_gateway_id=local_gateway_id, expected_epoch=int(room["authority_epoch"]), ) + stop_seq = int(stop_event["seq"]) requested = 0 for task in driver.list_tasks(self.db_path, room_id=room_id): + if int(task["payload"]["source_event_seq"]) >= stop_seq: + continue for _attempt in range(3): current = driver.get_task(self.db_path, task["identity"]) status = str(current.get("status") or "") @@ -1128,9 +1064,7 @@ def parse_room_command(args: str, *, command_root: str = "/group") -> RoomComman action = entity_first[1].casefold() remainder = entity_first[2].strip() if len(entity_first) == 3 else "" if action == "send": - message = remainder.removeprefix("--").strip() - if len(message) >= 2 and message[0] == message[-1] and message[0] in {'"', "'"}: - message = message[1:-1].strip() + message = remainder if not message: raise RoomControlError(f"Use `{command_root} send `.") return RoomCommand("send", room_query, message) @@ -1370,6 +1304,7 @@ def format_room_detail( room: Mapping[str, Any], *, room_command: str = "/group", + show_approvals: bool = False, ) -> str: """Render status plus the latest visible room messages.""" @@ -1494,14 +1429,15 @@ def format_room_detail( lines.extend(["", "No messages yet."]) from gateway.hosted_room_messaging_approvals import format_pending_approvals - approval_section = format_pending_approvals( - service, - room, - room_reference=str(room_reference(room)), - room_command=room_command, - ) - if approval_section: - lines.extend(["", approval_section]) + if show_approvals: + approval_section = format_pending_approvals( + service, + room, + room_reference=str(room_reference(room)), + room_command=room_command, + ) + if approval_section: + lines.extend(["", approval_section]) failed_commands = int(room.get("desktop_failed_commands") or 0) show_retry, show_stop = _room_action_flags( service, @@ -1631,6 +1567,9 @@ def messaging_event_id(event: Any) -> str: str(value or "") for value in ( platform, + getattr(source, "profile", None) or "default", + messaging_transport_profile(event), + getattr(source, "scope_id", None) or getattr(source, "guild_id", None), getattr(source, "chat_id", None), getattr(source, "thread_id", None), getattr(source, "user_id_alt", None) @@ -1642,6 +1581,21 @@ def messaging_event_id(event: Any) -> str: return f"messaging:{hashlib.sha256(material.encode()).hexdigest()}" +def messaging_transport_profile(event: Any) -> str: + """Return the locally trusted profile that owns the receiving adapter.""" + + source = getattr(event, "source", None) + adapter_ref = getattr(source, "_transport_adapter_ref", None) + try: + adapter = adapter_ref() if callable(adapter_ref) else adapter_ref + except Exception: + adapter = None + if adapter is not None: + owner = str(getattr(adapter, "_owner_profile", None) or "").strip() + return owner or "default" + return str(getattr(source, "profile", None) or "default") + + def ensure_text_only(event: Any) -> None: """Reject media explicitly until hosted-room attachment transport exists.""" @@ -1803,7 +1757,11 @@ def stop_room(service: Any, room: Mapping[str, Any], event: Any) -> str: and current.get("state") in {"claimed", "pending"} else "" ) - target_thread_id = _latest_projected_thread(room) + target_thread_id, target_message_id = _latest_projected_stop_target(room) + if not target_command_id and not target_message_id: + raise RoomControlError( + "Open this Group Chat in Hermes Desktop to Stop it safely." + ) enqueue_command( default_db_path(), command_id=cancel_id, @@ -1817,6 +1775,11 @@ def stop_room(service: Any, room: Mapping[str, Any], event: Any) -> str: else {} ), **({"target_thread_id": target_thread_id} if target_thread_id else {}), + **( + {"target_message_id": target_message_id} + if not target_command_id and target_message_id + else {} + ), }, ) if room.get("desktop_available"): @@ -1871,6 +1834,7 @@ def retry_room(service: Any, room: Mapping[str, Any], event: Any) -> str: return f"Retry queued for {name} ({retried} {suffix})." if room.get("_room_mode") == "desktop": from gateway.desktop_room_mailbox import ( + DesktopRoomMailboxError, default_db_path, retry_failed_commands, retryable_command_ids, @@ -1888,10 +1852,15 @@ def retry_room(service: Any, room: Mapping[str, Any], event: Any) -> str: except RoomControlError as exc: if str(exc) != "This Group Chat has no failed work to retry.": raise - target_ids = retryable_command_ids( - mailbox_db, - room_id=room_id, - ) + try: + target_ids = retryable_command_ids( + mailbox_db, + room_id=room_id, + ) + except DesktopRoomMailboxError as mailbox_error: + raise RoomControlError( + "This Group Chat has no failed work to retry." + ) from mailbox_error frozen, completed = _retry_receipt_plan( receipt_db, command_id=command_id, diff --git a/gateway/hosted_room_messaging_approvals.py b/gateway/hosted_room_messaging_approvals.py index 723a928bd21c3..9265e5129eb3d 100644 --- a/gateway/hosted_room_messaging_approvals.py +++ b/gateway/hosted_room_messaging_approvals.py @@ -850,9 +850,15 @@ def submit_room_approval( *, command_id: str, choice: str, + installation_owner_authorized: bool, selection: str = "", expected_request_id: str = "", ) -> tuple[int, dict[str, Any], dict[str, Any]]: + if installation_owner_authorized is not True: + raise MessagingApprovalError( + "Approve or deny Bot commands from the installation owner’s " + "authorized one-to-one Hermes chat." + ) if room.get("_room_mode") == "desktop": raise MessagingApprovalError( "This Group Chat runs in Desktop. Approve or deny the command there." diff --git a/gateway/hosted_room_messaging_retries.py b/gateway/hosted_room_messaging_retries.py new file mode 100644 index 0000000000000..0f085ddfecd19 --- /dev/null +++ b/gateway/hosted_room_messaging_retries.py @@ -0,0 +1,205 @@ +"""Bounded idempotency receipts for messaging-triggered Group Chat retries.""" + +from __future__ import annotations + +import json +import sqlite3 +import time +from collections.abc import Mapping +from pathlib import Path +from typing import Any + + +MAX_RETRY_RECEIPTS = 4096 +RETRY_RECEIPT_RETENTION_SECONDS = 30 * 24 * 60 * 60 +PENDING_RETRY_RECEIPT_TTL_SECONDS = 24 * 60 * 60 +EXPIRED_RETRY_RESULT = ( + "This Retry expired before Hermes could confirm its outcome. " + "Send a new Retry command." +) + + +class MessagingRetryReceiptError(ValueError): + """Raised when a retry receipt conflicts or cannot be admitted safely.""" + + +def _prepare(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS hosted_room_messaging_retries ( + command_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + actor_json TEXT NOT NULL, + task_ids_json TEXT NOT NULL, + state TEXT NOT NULL, + result_text TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""" + ) + conn.execute( + """CREATE INDEX IF NOT EXISTS idx_hosted_room_messaging_retries_retention + ON hosted_room_messaging_retries(state, updated_at, command_id)""" + ) + + +def _prune_receipts(conn: sqlite3.Connection, *, now: float) -> int: + conn.execute( + """UPDATE hosted_room_messaging_retries + SET state='expired', result_text=?, updated_at=? + WHERE state='pending' AND updated_at= MAX_RETRY_RECEIPTS: + conn.execute( + """DELETE FROM hosted_room_messaging_retries + WHERE command_id IN ( + SELECT command_id FROM hosted_room_messaging_retries + WHERE state='completed' + ORDER BY updated_at, command_id + LIMIT ? + )""", + (count - MAX_RETRY_RECEIPTS + 1,), + ) + count = int( + conn.execute( + "SELECT COUNT(*) FROM hosted_room_messaging_retries" + ).fetchone()[0] + ) + return count + + +def retry_receipt_plan( + db_path: Path, + *, + command_id: str, + room_id: str, + actor: Mapping[str, Any], + task_ids: list[str], + now: float | None = None, +) -> tuple[list[str], str | None]: + """Freeze one delivery to a bounded retry decision.""" + + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path, timeout=10) + conn.row_factory = sqlite3.Row + try: + from hermes_state import apply_wal_with_fallback + + apply_wal_with_fallback(conn, db_label="state.db (Group Chat retry receipts)") + conn.execute("BEGIN IMMEDIATE") + _prepare(conn) + timestamp = time.time() if now is None else float(now) + receipt_count = _prune_receipts(conn, now=timestamp) + encoded_actor = json.dumps( + dict(actor), ensure_ascii=True, sort_keys=True, separators=(",", ":") + ) + existing = conn.execute( + "SELECT * FROM hosted_room_messaging_retries WHERE command_id=?", + (command_id,), + ).fetchone() + if existing is not None: + if ( + str(existing["room_id"]) != room_id + or str(existing["actor_json"]) != encoded_actor + ): + raise MessagingRetryReceiptError( + "This retry delivery was already used for different Group Chat work." + ) + frozen = [ + str(item) + for item in json.loads(str(existing["task_ids_json"])) + if str(item) + ] + result = ( + str(existing["result_text"]) + if existing["state"] in {"completed", "expired"} + and existing["result_text"] + else None + ) + if existing["state"] == "pending": + conn.execute( + """UPDATE hosted_room_messaging_retries SET updated_at=? + WHERE command_id=? AND state='pending'""", + (timestamp, command_id), + ) + conn.commit() + return frozen, result + if not task_ids: + raise MessagingRetryReceiptError( + "This Group Chat has no failed work to retry." + ) + if receipt_count >= MAX_RETRY_RECEIPTS: + raise MessagingRetryReceiptError( + "Stored Group Chat retries are full. Finish pending retries and try again." + ) + conn.execute( + """INSERT INTO hosted_room_messaging_retries ( + command_id, room_id, actor_json, task_ids_json, state, + result_text, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', NULL, ?, ?)""", + ( + command_id, + room_id, + encoded_actor, + json.dumps(task_ids, ensure_ascii=True, separators=(",", ":")), + timestamp, + timestamp, + ), + ) + conn.commit() + return task_ids, None + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def complete_retry_receipt( + db_path: Path, + *, + command_id: str, + result: str, + now: float | None = None, +) -> None: + conn = sqlite3.connect(db_path, timeout=10) + try: + conn.execute("BEGIN IMMEDIATE") + changed = conn.execute( + """UPDATE hosted_room_messaging_retries + SET state='completed', result_text=?, updated_at=? + WHERE command_id=? AND state='pending'""", + (result, time.time() if now is None else float(now), command_id), + ) + if changed.rowcount not in {0, 1}: + raise RuntimeError("Group Chat retry receipt changed more than once") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/gateway/run.py b/gateway/run.py index 9e679f73056b3..22595ea3ea750 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -25616,7 +25616,7 @@ async def _on_confirm(choice: str): return result return result - _p = self._typed_command_prefix_for(event.source.platform) + _p = self._typed_command_prefix_for(event.source) prompt_message = ( f"⚠️ **Confirm /{command}**\n\n" f"{detail}\n\n" diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 9277945e3c748..476e543cbf7bd 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -132,7 +132,7 @@ class GatewaySlashCommandsMixin( async_session_store: AsyncSessionStore - def _typed_command_prefix_for(self, platform) -> str: + def _typed_command_prefix_for(self, source_or_platform) -> str: """Return the prefix users can always type to reach Hermes commands. Reads the adapter's ``typed_command_prefix`` capability flag @@ -142,7 +142,27 @@ def _typed_command_prefix_for(self, platform) -> str: Instruction text built for those platforms must show the prefix that actually works when typed. """ - adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None + source = ( + source_or_platform + if getattr(source_or_platform, "platform", None) is not None + else None + ) + platform = source.platform if source is not None else source_or_platform + if ( + source is not None + and getattr(source, "delivered_via_upstream_relay", False) is True + and platform in {Platform.SLACK, Platform.MATRIX} + ): + return "!" + adapter = ( + self._adapter_for_source(source) + if source is not None + else ( + self.adapters.get(platform) + if getattr(self, "adapters", None) + else None + ) + ) return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/" async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: @@ -2531,7 +2551,7 @@ async def _on_cost_confirm(choice: str) -> str: # an explicit decision). return await _finish_switch() - _p = self._typed_command_prefix_for(event.source.platform) + _p = self._typed_command_prefix_for(event.source) return await self._request_slash_confirm( event=event, command="model", diff --git a/tests/gateway/platforms/test_api_server_room_controls.py b/tests/gateway/platforms/test_api_server_room_controls.py index 48c1ee208720c..fa3be0dd1e32f 100644 --- a/tests/gateway/platforms/test_api_server_room_controls.py +++ b/tests/gateway/platforms/test_api_server_room_controls.py @@ -247,3 +247,22 @@ async def test_retry_is_queued_for_the_process_that_owns_the_room_lease( assert [(item.command_id, item.task_ids) for item in pending] == [ ("remote-retry-worker", ("task-1",)) ] + + +@pytest.mark.asyncio +async def test_empty_remote_retry_returns_the_shared_actionable_error(control_api): + _adapter, app, service, headers = control_api + service.retried.append("already-settled") + + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/v1/room-controls/room-1", + json={"action": "retry", "command_id": "remote-retry-empty"}, + headers=headers, + ) + payload = await response.json() + + assert response.status == 400 + assert payload["error"]["message"] == ( + "This Group Chat has no failed work to retry." + ) diff --git a/tests/gateway/test_desktop_room_mailbox.py b/tests/gateway/test_desktop_room_mailbox.py index 9c4b93e3193d3..96f9fcfae3af8 100644 --- a/tests/gateway/test_desktop_room_mailbox.py +++ b/tests/gateway/test_desktop_room_mailbox.py @@ -182,6 +182,35 @@ def test_expired_claim_is_recovered_by_the_registered_desktop(tmp_path): clock=clock, ) + clock.value += mailbox.CLAIM_TTL_SECONDS + 1 + assert ( + mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + == [] + ) + exhausted = mailbox.latest_command_states(db, ["room-1"])["room-1"] + assert exhausted["state"] == "failed" + assert exhausted["result"]["code"] == "automatic_attempts_exhausted" + + retried = mailbox.retry_failed_command( + db, + room_id="room-1", + command_id="messaging:retry", + clock=clock, + ) + assert retried["attempts"] == 0 + reclaimed = mailbox.claim_commands( + db, + consumer_id="desktop:first", + room_authorities=authorities("room-1"), + clock=clock, + ) + assert reclaimed[0]["attempts"] == 1 + def test_completion_ack_retry_is_idempotent(tmp_path): db = tmp_path / "state.db" @@ -251,6 +280,7 @@ def test_explicit_retry_requeues_one_failed_command_idempotently(tmp_path): command_id=claimed["command_id"], ) assert retried["state"] == "pending" + assert retried["attempts"] == 0 assert replay["idempotent"] is True reclaimed = mailbox.claim_commands( db, @@ -258,6 +288,7 @@ def test_explicit_retry_requeues_one_failed_command_idempotently(tmp_path): room_authorities=authorities("room-1"), ) assert reclaimed[0]["command_id"] == "messaging:failed" + assert reclaimed[0]["attempts"] == 1 def test_reclaim_rotates_token_and_fences_a_stale_attempt(tmp_path): @@ -776,6 +807,7 @@ def test_retry_requeues_all_bounded_expired_commands_oldest_first(tmp_path): clock=clock, ) assert [command["payload"]["message"] for command in claimed] == ["0", "1"] + assert [command["attempts"] for command in claimed] == [1, 1] def test_pending_queue_is_bounded_per_group_chat(tmp_path, monkeypatch): diff --git a/tests/gateway/test_hosted_room_controls.py b/tests/gateway/test_hosted_room_controls.py index 77dc4c5623244..c57cf84792d7f 100644 --- a/tests/gateway/test_hosted_room_controls.py +++ b/tests/gateway/test_hosted_room_controls.py @@ -531,6 +531,74 @@ def test_remote_retry_plan_and_result_are_idempotent_and_conflict_safe(tmp_path) ) +def test_completed_control_retries_make_room_at_the_cap(tmp_path, monkeypatch): + db = tmp_path / "state.db" + monkeypatch.setattr(controls, "MAX_CONTROL_COMMANDS", 2) + controls.begin_control_retry( + db, + command_id="completed", + room_id="room-1", + member_id="member-1", + task_ids=["task-1"], + now=1, + ) + controls.complete_control_retry( + db, + command_id="completed", + result={"retried": 1}, + now=2, + ) + controls.begin_control_retry( + db, + command_id="pending", + room_id="room-1", + member_id="member-1", + task_ids=["task-2"], + now=3, + ) + + admitted = controls.begin_control_retry( + db, + command_id="new", + room_id="room-1", + member_id="member-1", + task_ids=["task-3"], + now=4, + ) + + assert admitted.task_ids == ("task-3",) + with sqlite3.connect(db) as conn: + rows = conn.execute( + "SELECT command_id, state FROM hosted_room_control_commands " + "ORDER BY command_id" + ).fetchall() + assert rows == [("new", "pending"), ("pending", "pending")] + + +def test_pending_control_retries_fail_closed_at_the_cap(tmp_path, monkeypatch): + db = tmp_path / "state.db" + monkeypatch.setattr(controls, "MAX_CONTROL_COMMANDS", 2) + for index in range(2): + controls.begin_control_retry( + db, + command_id=f"pending-{index}", + room_id="room-1", + member_id="member-1", + task_ids=[f"task-{index}"], + now=index + 1, + ) + + with pytest.raises(controls.HostedRoomControlError, match="limit reached"): + controls.begin_control_retry( + db, + command_id="pending-3", + room_id="room-1", + member_id="member-1", + task_ids=["task-3"], + now=3, + ) + + @pytest.mark.parametrize( "stored_task_ids", ["not-json", '{"task-real":true}', '"task-real"'], diff --git a/tests/gateway/test_hosted_room_messaging.py b/tests/gateway/test_hosted_room_messaging.py index a45b6adaf815b..de650d3a921c0 100644 --- a/tests/gateway/test_hosted_room_messaging.py +++ b/tests/gateway/test_hosted_room_messaging.py @@ -704,6 +704,37 @@ def test_legacy_desktop_room_control_requests_one_current_desktop_open( ) +def test_classic_stop_targets_the_latest_user_message_not_only_its_thread( + tmp_path, monkeypatch +): + from gateway import desktop_room_mailbox + + home = tmp_path / "hermes" + _seed_classic_projection(home) + monkeypatch.setenv("HERMES_HOME", str(home)) + service = _FakeService(tmp_path / "state.db") + room = list_messaging_rooms(service)[0] + + stop_room( + service, + room, + _event("/group 1 stop", message_id="stop-message-fence"), + ) + claimed = desktop_room_mailbox.claim_commands( + desktop_room_mailbox.default_db_path(), + consumer_id="desktop:test", + room_authorities=[ + {"room_id": "classic-room", "authority_token": "authority:test"} + ], + actions=["stop"], + ) + + assert claimed[0]["payload"] == { + "target_message_id": "message-1", + "target_thread_id": "thread-1", + } + + def test_classic_room_detail_surfaces_failed_command_recovery(tmp_path, monkeypatch): from gateway import desktop_room_mailbox @@ -797,8 +828,10 @@ def test_classic_retry_requeues_all_expired_commands_and_replays_receipt( @pytest.mark.parametrize( ("raw", "expected"), [ - ('1 send "hi there"', ("send", "1", "hi there")), - ("1 send -- quoted style", ("send", "1", "quoted style")), + ('1 send "hi there"', ("send", "1", '"hi there"')), + ('1 send "a" and "b"', ("send", "1", '"a" and "b"')), + ("1 send --literal-prefix", ("send", "1", "--literal-prefix")), + ("1 send -- quoted style", ("send", "1", "-- quoted style")), ("1 stop", ("stop", "1", "")), ("1 retry", ("retry", "1", "")), ("1 approve", ("approve", "1", "")), @@ -811,6 +844,28 @@ def test_parse_room_command_keeps_names_and_message_content(raw, expected): assert (parsed.action, parsed.room_query, parsed.message) == expected +def test_empty_classic_retry_keeps_actionable_user_error(tmp_path, monkeypatch): + from gateway.desktop_room_mailbox import DesktopRoomMailboxError + + service = _FakeService(tmp_path / "state.db") + room = { + "room_id": "classic-room", + "name": "Desktop planning", + "_room_mode": "desktop", + } + + def no_retryable_commands(*_args, **_kwargs): + raise DesktopRoomMailboxError("no failed Group Chat command needs retry") + + monkeypatch.setattr( + "gateway.desktop_room_mailbox.retryable_command_ids", + no_retryable_commands, + ) + + with pytest.raises(RoomControlError, match="no failed work to retry"): + retry_room(service, room, _event("/group 1 retry", message_id="empty-retry")) + + @pytest.mark.parametrize("raw", ["", "send room", "send -- hello", "stop"]) def test_parse_room_command_returns_actionable_usage(raw): with pytest.raises(RoomControlError, match="Use `/group"): @@ -1762,6 +1817,7 @@ def test_group_detail_surfaces_exact_pending_approval_commands(tmp_path): detail = format_room_detail( MessagingRoomBackend(db_path=db), release, + show_approvals=True, ) assert "⚠️ **Approval needed**" in detail diff --git a/tests/gateway/test_hosted_room_messaging_approvals.py b/tests/gateway/test_hosted_room_messaging_approvals.py index 6b9ead16dc3e8..deaafd982a635 100644 --- a/tests/gateway/test_hosted_room_messaging_approvals.py +++ b/tests/gateway/test_hosted_room_messaging_approvals.py @@ -586,6 +586,7 @@ class Backend: {"room_id": "classic-room", "_room_mode": "desktop"}, command_id="approval-command-1", choice="once", + installation_owner_authorized=True, ) with pytest.raises( @@ -597,6 +598,25 @@ class Backend: {"room_id": "remote-room", "_room_mode": "remote"}, command_id="approval-command-2", choice="once", + installation_owner_authorized=True, + ) + + +def test_secondary_profile_cannot_submit_room_approval(tmp_path): + class Backend: + db_path = tmp_path / "state.db" + service = None + + with pytest.raises( + approvals.MessagingApprovalError, + match="installation owner", + ): + approvals.submit_room_approval( + Backend(), + {"room_id": "room-1"}, + command_id="approval-command-secondary", + choice="once", + installation_owner_authorized=False, ) diff --git a/tests/gateway/test_hosted_room_messaging_retries.py b/tests/gateway/test_hosted_room_messaging_retries.py new file mode 100644 index 0000000000000..2483157194cda --- /dev/null +++ b/tests/gateway/test_hosted_room_messaging_retries.py @@ -0,0 +1,92 @@ +"""Retention and idempotency tests for messaging Retry receipts.""" + +import sqlite3 + +import pytest + +from gateway import hosted_room_messaging_retries as retries + + +def _plan(db, command_id, *, now, task_id="task-1"): + return retries.retry_receipt_plan( + db, + command_id=command_id, + room_id="room-1", + actor={"kind": "user", "id": "owner"}, + task_ids=[task_id], + now=now, + ) + + +def test_completed_receipts_make_room_without_pruning_pending(tmp_path, monkeypatch): + db = tmp_path / "state.db" + monkeypatch.setattr(retries, "MAX_RETRY_RECEIPTS", 2) + _plan(db, "completed", now=1) + retries.complete_retry_receipt( + db, + command_id="completed", + result="done", + now=2, + ) + _plan(db, "pending", now=3) + + assert _plan(db, "new", now=4, task_id="task-2") == (["task-2"], None) + + with sqlite3.connect(db) as conn: + rows = conn.execute( + "SELECT command_id, state FROM hosted_room_messaging_retries " + "ORDER BY command_id" + ).fetchall() + assert rows == [("new", "pending"), ("pending", "pending")] + + +def test_pending_receipts_fail_closed_at_the_cap(tmp_path, monkeypatch): + db = tmp_path / "state.db" + monkeypatch.setattr(retries, "MAX_RETRY_RECEIPTS", 2) + _plan(db, "pending-1", now=1) + _plan(db, "pending-2", now=2) + + with pytest.raises(retries.MessagingRetryReceiptError, match="Finish pending"): + _plan(db, "pending-3", now=3) + + +def test_abandoned_pending_receipt_becomes_non_retargetable_tombstone( + tmp_path, monkeypatch +): + db = tmp_path / "state.db" + monkeypatch.setattr(retries, "MAX_RETRY_RECEIPTS", 2) + monkeypatch.setattr(retries, "PENDING_RETRY_RECEIPT_TTL_SECONDS", 10) + monkeypatch.setattr(retries, "RETRY_RECEIPT_RETENTION_SECONDS", 20) + _plan(db, "same-delivery", now=1, task_id="old-task") + + assert _plan(db, "same-delivery", now=20, task_id="new-task") == ( + ["old-task"], + retries.EXPIRED_RETRY_RESULT, + ) + assert _plan(db, "pending", now=20) == (["task-1"], None) + assert _plan(db, "new", now=41) == (["task-1"], None) + + with sqlite3.connect(db) as conn: + rows = conn.execute( + "SELECT command_id, state FROM hosted_room_messaging_retries " + "ORDER BY command_id" + ).fetchall() + assert rows == [("new", "pending"), ("pending", "expired")] + + +def test_expired_completed_receipts_are_pruned(tmp_path, monkeypatch): + db = tmp_path / "state.db" + monkeypatch.setattr(retries, "RETRY_RECEIPT_RETENTION_SECONDS", 10) + _plan(db, "old", now=1) + retries.complete_retry_receipt(db, command_id="old", result="done", now=2) + + _plan(db, "new", now=20) + + with sqlite3.connect(db) as conn: + assert ( + conn.execute( + "SELECT COUNT(*) FROM hosted_room_messaging_retries " + "WHERE command_id='old'" + ).fetchone()[0] + == 0 + ) diff --git a/tests/gateway/test_hosted_room_messaging_security.py b/tests/gateway/test_hosted_room_messaging_security.py index d05ba9d3c6596..132681b1b1d9f 100644 --- a/tests/gateway/test_hosted_room_messaging_security.py +++ b/tests/gateway/test_hosted_room_messaging_security.py @@ -64,6 +64,24 @@ async def test_dm_label_without_one_to_one_proof_cannot_control_group_chats( ) +@pytest.mark.asyncio +async def test_unknown_chat_type_fails_closed_even_with_private_transport_proof( + tmp_path, monkeypatch +): + service = _FakeService(tmp_path / "state.db") + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + + result = await _runner()._handle_rooms_command( + _event("/group", chat_type="", is_one_to_one=True) + ) + + assert result == ( + "Group Chat controls are private. Use your authorized one-to-one Hermes chat." + ) + + @pytest.mark.asyncio @pytest.mark.parametrize( "platform", @@ -137,6 +155,33 @@ async def test_mutating_group_chat_commands_have_a_per_sender_rate_limit( assert len(service.sent) == 2 +@pytest.mark.asyncio +async def test_rate_limits_are_isolated_by_receiving_adapter_profile( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + runner = _runner() + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + monkeypatch.setattr("gateway.group_chat_slash._GROUP_CHAT_MUTATION_RATE_LIMIT", 1) + secondary = _event("/group 1 send from secondary", message_id="rate-secondary") + secondary.source.profile = "default" + secondary.source._transport_adapter_ref = lambda: SimpleNamespace( + _owner_profile="ops" + ) + primary = _event("/group 1 send from primary", message_id="rate-primary") + primary.source._transport_adapter_ref = lambda: SimpleNamespace(_owner_profile=None) + + first = await runner._handle_rooms_command(secondary) + second = await runner._handle_rooms_command(primary) + + assert first.startswith("Queued in") + assert second.startswith("Queued in") + assert len(service.sent) == 2 + + @pytest.mark.parametrize( "raw_message", [ @@ -171,6 +216,133 @@ def test_signal_group_idempotency_includes_sender_identity(): assert messaging_event_id(first) != messaging_event_id(second) +def test_messaging_idempotency_includes_profile_and_workspace_scope(): + default = _event("/group 1 send hello", platform=Platform.TELEGRAM) + secondary = _event("/group 1 send hello", platform=Platform.TELEGRAM) + secondary.source.profile = "ops" + assert messaging_event_id(default) != messaging_event_id(secondary) + + first_workspace = _event("/group 1 send hello", platform=Platform.SLACK) + second_workspace = _event("/group 1 send hello", platform=Platform.SLACK) + first_workspace.source.scope_id = "workspace-a" + second_workspace.source.scope_id = "workspace-b" + assert messaging_event_id(first_workspace) != messaging_event_id(second_workspace) + + +def test_messaging_idempotency_includes_receiving_adapter_profile(): + default = _event("/group 1 send hello", platform=Platform.TELEGRAM) + secondary = _event("/group 1 send hello", platform=Platform.TELEGRAM) + default.source.profile = secondary.source.profile = "default" + default.source._transport_adapter_ref = lambda: SimpleNamespace(_owner_profile=None) + secondary.source._transport_adapter_ref = lambda: SimpleNamespace( + _owner_profile="ops" + ) + + assert messaging_event_id(default) != messaging_event_id(secondary) + + +def test_secondary_profile_adapter_supplies_the_typed_command_prefix(): + runner = _runner(platform=Platform.SLACK) + runner.adapters = {} + runner._profile_adapters = { + "ops": {Platform.SLACK: SimpleNamespace(typed_command_prefix="!")} + } + source = _event("!group", platform=Platform.SLACK).source + source.profile = "ops" + + assert runner._typed_command_prefix_for(source) == "!" + + +@pytest.mark.parametrize("platform", [Platform.SLACK, Platform.MATRIX]) +def test_relayed_platform_keeps_its_usable_typed_command_prefix(platform): + runner = _runner(platform=platform) + source = _event("!group", platform=platform).source + source.delivered_via_upstream_relay = True + + assert runner._typed_command_prefix_for(source) == "!" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "command", + ["/group 1 approvals", "/group 1 approve ABCDEF"], +) +async def test_secondary_profile_cannot_view_or_apply_group_chat_approvals( + tmp_path, monkeypatch, command +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event(command) + event.source.profile = "ops" + + result = await _runner()._handle_rooms_command(event) + + assert "installation owner" in result + + +@pytest.mark.asyncio +async def test_secondary_adapter_routed_to_default_cannot_apply_approval( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + event = _event("/group 1 approve ABCDEF") + event.source.profile = "default" + event.source._transport_adapter_ref = lambda: SimpleNamespace(_owner_profile="ops") + + result = await _runner()._handle_rooms_command(event) + + assert "installation owner" in result + + +@pytest.mark.asyncio +async def test_secondary_profile_room_detail_hides_approval_command_and_code( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + service.room_status = { + "running": True, + "working": False, + "blocked": True, + "pending_actions": [ + { + "kind": "approval", + "authority_gateway_id": "install:test-gateway", + "authority_epoch": 1, + "member_id": "ops", + "task_id": "task-secret", + "execution_generation": 1, + "request_id": "request-secret", + "approval": { + "description": "Read a private deployment key", + "command": "cat /private/deployment-key", + "choices": ["once", "deny"], + }, + } + ], + } + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + secondary = _event("/group 1") + secondary.source.profile = "ops" + + hidden = await _runner()._handle_rooms_command(secondary) + visible = await _runner()._handle_rooms_command(_event("/group 1")) + + assert "deployment-key" not in hidden + assert "Approval needed" not in hidden + assert "deployment-key" in visible + assert "Approval needed" in visible + + @pytest.mark.asyncio @pytest.mark.parametrize("platform", [p for p in Platform if p is not Platform.LOCAL]) async def test_send_handler_is_shared_by_every_gateway_channel( @@ -213,6 +385,25 @@ async def test_entity_first_group_send_is_dispatched(tmp_path, monkeypatch): assert service.sent[-1]["payload"]["text"] == "hello from Signal" +@pytest.mark.asyncio +async def test_group_send_preserves_unicode_dashes_in_message_text( + tmp_path, monkeypatch +): + db, _, _ = _seed_rooms(tmp_path) + service = _FakeService(db) + monkeypatch.setattr( + "gateway.hosted_room_messaging.current_room_backend", lambda: service + ) + message = "Ship—today; validate range 1–3" + + result = await _runner()._handle_rooms_command( + _event(f"/group 1 send {message}", message_id="unicode-dash-send") + ) + + assert result.startswith("Queued in Release room") + assert service.sent[-1]["payload"]["text"] == message + + @pytest.mark.asyncio async def test_send_rejects_attachments_instead_of_silently_dropping_them( tmp_path, monkeypatch diff --git a/tests/tui_gateway/test_hosted_room_service.py b/tests/tui_gateway/test_hosted_room_service.py index 3b399e6b21091..5634e0b08cf53 100644 --- a/tests/tui_gateway/test_hosted_room_service.py +++ b/tests/tui_gateway/test_hosted_room_service.py @@ -319,7 +319,12 @@ def test_stop_room_snapshots_tasks_before_status_transitions(monkeypatch, tmp_pa """One running task must not be counted again after it becomes stopping.""" identity = driver.TaskIdentity("room-1", "task-1", "thread-1", "turn-1") - task = {"identity": identity, "status": "running", "cancel_id": None} + task = { + "identity": identity, + "payload": {"source_event_seq": 1}, + "status": "running", + "cancel_id": None, + } calls = [] def listed(_db, *, room_id, status): @@ -339,6 +344,7 @@ def cancel(_identity, *, cancel_id): lambda _db, *, room_id, cancel_id, **_authority: { "room_id": room_id, "cancel_id": cancel_id, + "seq": 2, }, ) service = HostedRoomService(_server(), db_path=tmp_path / "state.db") diff --git a/tests/tui_gateway/test_hosted_room_stop_replay.py b/tests/tui_gateway/test_hosted_room_stop_replay.py new file mode 100644 index 0000000000000..785f2b527f33c --- /dev/null +++ b/tests/tui_gateway/test_hosted_room_stop_replay.py @@ -0,0 +1,78 @@ +"""A replayed Stop must not cancel work created after its durable fence.""" + +import time + +from gateway import hosted_room_driver as driver +from gateway import hosted_rooms +from gateway.hosted_room_messaging import MessagingRoomBackend +from tui_gateway.hosted_room_service import HostedRoomService + +from tests.tui_gateway.test_hosted_room_service import _server + + +def _room(db): + return hosted_rooms.create_room( + db, + room_id="room-1", + name="Release room", + members=[ + {"member_id": "one", "profile": "one", "handle": "one"}, + {"member_id": "two", "profile": "two", "handle": "two"}, + ], + authority_gateway_id=hosted_rooms.local_authority_gateway_id(), + ) + + +def _queue(db, *, task_id, event_id, text): + room = hosted_rooms.room_state(db, room_id="room-1") + event = hosted_rooms.append_event( + db, + room_id="room-1", + event_id=event_id, + kind="message.user", + actor={"kind": "user", "id": "owner"}, + payload={"text": text, "thread_id": event_id}, + authority_gateway_id=str(room["authority_gateway_id"]), + authority_epoch=int(room["authority_epoch"]), + ) + identity = driver.TaskIdentity("room-1", task_id, event_id, f"turn-{task_id}") + driver.admit_task( + db, + identity, + payload={ + "target_member_id": "one", + "target_profile": "one", + "prompt": text, + "source_event_seq": int(event["seq"]), + }, + clock=time.time, + ) + return identity + + +def test_service_stop_replay_does_not_cancel_later_task(tmp_path): + db = tmp_path / "state.db" + _room(db) + first = _queue(db, task_id="first", event_id="user-1", text="First") + service = HostedRoomService(_server(), db_path=db) + + assert service.stop_room("room-1", cancel_id="stop-1") == 1 + assert driver.get_task(db, first)["status"] == "cancelled" + later = _queue(db, task_id="later", event_id="user-2", text="Later") + + assert service.stop_room("room-1", cancel_id="stop-1") == 0 + assert driver.get_task(db, later)["status"] == "queued" + + +def test_store_only_stop_replay_does_not_cancel_later_task(tmp_path): + db = tmp_path / "state.db" + _room(db) + first = _queue(db, task_id="first", event_id="user-1", text="First") + backend = MessagingRoomBackend(db_path=db) + + assert backend.stop_room("room-1", cancel_id="stop-1") == 1 + assert driver.get_task(db, first)["status"] == "cancelled" + later = _queue(db, task_id="later", event_id="user-2", text="Later") + + backend.stop_room("room-1", cancel_id="stop-1") + assert driver.get_task(db, later)["status"] == "queued" diff --git a/tui_gateway/hosted_room_service.py b/tui_gateway/hosted_room_service.py index f9aaa604aa683..5d610033266c4 100644 --- a/tui_gateway/hosted_room_service.py +++ b/tui_gateway/hosted_room_service.py @@ -1168,13 +1168,14 @@ def stop_room( require_acknowledged: bool = False, ) -> int: room = self._owned_room(room_id) - hosted_rooms.request_room_stop( + stop_event = hosted_rooms.request_room_stop( self.db_path, room_id=room_id, cancel_id=cancel_id, expected_gateway_id=str(room["authority_gateway_id"]), expected_epoch=int(room["authority_epoch"]), ) + stop_seq = int(stop_event["seq"]) cancelled = 0 pending = 0 with self._policy_lock: @@ -1191,6 +1192,8 @@ def stop_room( room_id=room_id, status=status, ): + if int(task["payload"]["source_event_seq"]) >= stop_seq: + continue identity = task["identity"] tasks[(identity.room_id, identity.task_id)] = task for task in tasks.values(): From c84e7eaef6a3eb07b2369c7200b1b3def2ee9599 Mon Sep 17 00:00:00 2001 From: "Axl Ibiza, MBA" Date: Tue, 1 Sep 2026 08:01:02 -0500 Subject: [PATCH 09/16] test(bot-mode): pin non-interrupting mobile group commands --- tests/gateway/test_hosted_room_messaging.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/gateway/test_hosted_room_messaging.py b/tests/gateway/test_hosted_room_messaging.py index de650d3a921c0..5bcd640fbcc11 100644 --- a/tests/gateway/test_hosted_room_messaging.py +++ b/tests/gateway/test_hosted_room_messaging.py @@ -40,7 +40,11 @@ ) from gateway.platforms.base import MessageEvent, MessageType, SendResult from gateway.session import SessionSource -from hermes_cli.commands import resolve_command +from hermes_cli.commands import ( + is_interrupt_then_dispatch, + resolve_command, + should_bypass_active_session, +) from tui_gateway.hosted_room_service import HostedRoomService @@ -338,8 +342,12 @@ def _seed_classic_projection(home, *, room_id="classic-room"): def test_room_commands_are_gateway_dispatchable_without_interrupting_agent(): group = resolve_command("group") - assert group is not None and group.gateway_only and group.busy_policy == "dispatch" + assert group is not None + assert group.gateway_only is True + assert group.busy_policy == "dispatch" assert group.subcommands == () + assert should_bypass_active_session("group") is True + assert is_interrupt_then_dispatch("group") is False assert resolve_command("groups") is None assert resolve_command("rooms") is None From 6a746344e42a8cdbbf614e3b3730187dc55f8934 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Tue, 18 Aug 2026 21:18:32 +0800 Subject: [PATCH 10/16] fix(gateway): stop lease-wait refreshes flooding non-editing chat adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-process session turn lease waits emit a periodic 'Still waiting' refresh roughly every 15s. On adapters without send_or_update_status the status path falls back to a plain send, so each refresh landed as another standalone chat message — eight messages for a two-minute wait on WeCom/Weixin/QQ/Signal-class adapters, drowning the eventual delivery. Extract the refresh wording into a module-level template constant and derive a compile-once matcher from it, then suppress the periodic refresh in the status callback when the adapter cannot update the status in place. Adapters that can (Telegram, Slack) keep refreshing the existing bubble. The initial wait notice and the lease-timeout warning use different wording and are always delivered. (cherry picked from commit 6cb8e33f7be7b0444bdf06ee8c6d20c2d3275354) --- gateway/run.py | 47 ++++++++ run_agent.py | 15 ++- .../test_session_lease_wait_refresh.py | 107 ++++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/gateway/test_session_lease_wait_refresh.py diff --git a/gateway/run.py b/gateway/run.py index c1dd89990c91d..bd8ff8b9030cf 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1173,6 +1173,44 @@ def _clarify_send_then_wait(fut, *, clarify_id: str, session_key: str, clarify_m # Timeout or session-boundary cancellation return f"[user did not respond within {int(timeout / 60)}m]" return response +_SESSION_TURN_LEASE_REFRESH_RE = None + + +def _session_turn_lease_refresh_re(): + """Compile-once matcher for run_agent's periodic lease-wait refresh. + + Derived from the SAME template constant the emit site formats + (SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE, #89166) — never + re-inlined wording, same convention as _COMPRESSION_PROGRESS_STATUS_RE + (#69550). The import stays lazy because gateway/run.py never imports + run_agent at module scope. + """ + global _SESSION_TURN_LEASE_REFRESH_RE + if _SESSION_TURN_LEASE_REFRESH_RE is None: + from run_agent import SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE + + _SESSION_TURN_LEASE_REFRESH_RE = re.compile( + _status_template_to_regex(SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE), + re.IGNORECASE, + ) + return _SESSION_TURN_LEASE_REFRESH_RE + + +def _should_suppress_lease_wait_refresh(adapter, message: str) -> bool: + """True when a periodic turn-lease wait refresh would flood this adapter. + + The periodic refresh exists to update the initial "Another Hermes process + is using this session..." notice in place. On adapters without + ``send_or_update_status`` (WeCom, Weixin, QQ, Signal, ... — all + SUPPORTS_MESSAGE_EDITING = False) ``_send_or_update_status_coro`` falls + back to a plain send, so every ~15s refresh lands as another standalone + chat message (#89166). Suppress the refresh there; the initial notice and + the lease-timeout warning use different wording and are always delivered. + """ + if callable(getattr(adapter, "send_or_update_status", None)): + return False + return bool(_session_turn_lease_refresh_re().search(str(message or ""))) + def _resolve_progress_thread_id( @@ -5785,6 +5823,15 @@ def _status_callback_sync(self, event_type: str, message: str) -> None: _redact_gateway_user_facing_secrets(str(message or ""))[:160], ) return + if _should_suppress_lease_wait_refresh( + ctx._status_adapter, prepared_message + ): + logger.debug( + "suppressed periodic lease-wait refresh for %s: adapter has " + "no in-place status updates", + ctx.source.platform.value if ctx.source.platform else "unknown", + ) + return _fut = safe_schedule_threadsafe( _send_or_update_status_coro(ctx._status_adapter, ctx._status_chat_id, event_type, prepared_message, ctx._status_thread_metadata), ctx._loop_for_step, diff --git a/run_agent.py b/run_agent.py index 86b62db372675..c24d23e8a1ba8 100644 --- a/run_agent.py +++ b/run_agent.py @@ -310,6 +310,16 @@ def _is_ephemeral_scaffolding(msg: Any) -> bool: _MAX_TOOL_WORKERS = 8 +# Wording of the periodic refresh emitted while a cross-process session turn +# lease is held elsewhere (``_on_session_turn_lease_wait``). The refresh only +# makes sense on surfaces that can update the initial wait notice in place; +# gateway/run.py derives its no-in-place-update suppression matcher from this +# constant (#89166) — never re-inline the wording at either site. +SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE = ( + "⏳ Still waiting for the other Hermes process on " + "this session ({elapsed_seconds}s)..." +) + # Intrinsic marker stamped on a message dict once it has been written to the # SQLite session store. Used by ``_flush_messages_to_session_db`` to decide # what is already durable. An object-identity (``id(msg)``) dedup set cannot be @@ -9375,8 +9385,9 @@ def _on_session_turn_lease_wait(elapsed: float) -> None: ) else: self._emit_status( - "⏳ Still waiting for the other Hermes process on " - f"this session ({int(elapsed)}s)..." + SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE.format( + elapsed_seconds=int(elapsed) + ) ) if not _turn_db.acquire_session_turn_lease( diff --git a/tests/gateway/test_session_lease_wait_refresh.py b/tests/gateway/test_session_lease_wait_refresh.py new file mode 100644 index 0000000000000..30619d19c4a11 --- /dev/null +++ b/tests/gateway/test_session_lease_wait_refresh.py @@ -0,0 +1,107 @@ +"""Cross-process session lease-wait refresh must not flood chat gateways (#89166). + +While a durable session's turn lease is held by another Hermes process, +``run_agent._on_session_turn_lease_wait`` emits an initial wait notice once +and then a periodic "Still waiting ... (Ns)" refresh roughly every 15s. The +refresh only makes sense on surfaces that can update the initial notice in +place: on adapters without ``send_or_update_status`` (WeCom, Weixin, QQ, +Signal, ... — all SUPPORTS_MESSAGE_EDITING = False) the status path falls +back to a plain send, and a two-minute wait produced eight standalone +chat messages that drowned the eventual delivery. + +``_should_suppress_lease_wait_refresh`` gates the refresh by adapter +capability; the initial notice and the lease-timeout warning use different +wording and must always be delivered. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from gateway.config import Platform +from gateway.run import ( + _prepare_gateway_status_message, + _should_suppress_lease_wait_refresh, +) +from run_agent import SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE + +INITIAL_WAIT_NOTICE = ( + "⏳ Another Hermes process is using this session; " + "waiting for it to finish before starting your turn..." +) +LEASE_TIMEOUT_WARNING = ( + "⏳ Another Hermes process kept this session busy too " + "long. Your message was not processed - wait for the " + "other process to finish, then send it again." +) + + +def _adapter_without_status_update(): + # Shape of the WeCom/Weixin/QQ/Signal adapters: plain send, no + # send_or_update_status, SUPPORTS_MESSAGE_EDITING = False. + return SimpleNamespace(send=AsyncMock(), SUPPORTS_MESSAGE_EDITING=False) + + +def _adapter_with_status_update(): + # Shape of the Telegram/Slack adapters: refreshes edit the same bubble. + return SimpleNamespace( + send=AsyncMock(), + send_or_update_status=AsyncMock(), + SUPPORTS_MESSAGE_EDITING=True, + ) + + +def _prepared_refresh(elapsed_seconds: int) -> str: + message = SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE.format( + elapsed_seconds=elapsed_seconds + ) + prepared = _prepare_gateway_status_message( + Platform.WECOM, "lifecycle", message + ) + assert prepared is not None, "refresh must survive the noise filter first" + return prepared + + +def test_refresh_suppressed_on_adapter_without_in_place_updates(): + adapter = _adapter_without_status_update() + for elapsed in (15, 30, 105, 120): + assert ( + _should_suppress_lease_wait_refresh( + adapter, _prepared_refresh(elapsed) + ) + is True + ) + + +def test_refresh_delivered_when_adapter_updates_in_place(): + adapter = _adapter_with_status_update() + assert ( + _should_suppress_lease_wait_refresh(adapter, _prepared_refresh(15)) + is False + ) + + +def test_initial_wait_notice_never_suppressed(): + adapter = _adapter_without_status_update() + prepared = _prepare_gateway_status_message( + Platform.WECOM, "lifecycle", INITIAL_WAIT_NOTICE + ) + assert prepared is not None + assert _should_suppress_lease_wait_refresh(adapter, prepared) is False + + +def test_lease_timeout_warning_never_suppressed(): + adapter = _adapter_without_status_update() + prepared = _prepare_gateway_status_message( + Platform.WECOM, "lifecycle", LEASE_TIMEOUT_WARNING + ) + assert prepared is not None + assert _should_suppress_lease_wait_refresh(adapter, prepared) is False + + +def test_unrelated_lifecycle_status_never_suppressed(): + adapter = _adapter_without_status_update() + prepared = _prepare_gateway_status_message( + Platform.WECOM, "lifecycle", "Resumed session after gateway restart" + ) + assert prepared is not None + assert _should_suppress_lease_wait_refresh(adapter, prepared) is False From d0945caf03fcae846b0cad4ab3101b96089060ed Mon Sep 17 00:00:00 2001 From: "Axl Ibiza, MBA" Date: Tue, 1 Sep 2026 08:08:03 -0500 Subject: [PATCH 11/16] docs(bot-mode): explain mobile Group Chat control --- website/docs/reference/slash-commands.md | 3 +- website/docs/user-guide/bot-mode.md | 36 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 41dd223b7f3b9..9366dcea80d01 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -290,6 +290,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/memory [pending\|approve\|reject\|approval]` | Review pending memory writes staged by the write-approval gate (`memory.write_approval`) — approve or reject them right in chat — and toggle the gate with `/memory approval on\|off`. See [Controlling memory writes](/user-guide/features/memory#controlling-memory-writes-write_approval). | | `/skills [pending\|approve\|reject\|diff\|approval]` | Review pending **skill** writes staged by the write-approval gate (`skills.write_approval`). Shows a one-line gist per staged write; `/skills diff ` is truncated for chat — read the full diff on the CLI or in `~/.hermes/pending/skills/.json`. Only appears when the gate is on (or staged writes remain); search/install stay CLI-only. | | `/kanban ` | Drive the multi-profile, multi-project collaboration board from chat — identical argument surface to the CLI. Bypasses the running-agent guard, so `/kanban unblock t_abc`, `/kanban comment t_abc "…"`, `/kanban list --mine`, `/kanban boards switch `, etc. work mid-turn. `/kanban create …` auto-subscribes the originating chat to the new task's terminal events. See [Kanban slash command](/user-guide/features/kanban#kanban-slash-command). | +| `/group [list\| [bots\|bot @handle\|send \|retry\|stop]]` | **Messaging only.** Control Bot Group Chats from an authorized owner chat. Bare `/group` opens the recent-room picker where supported; `list` keeps every stable room number reachable; detail and `bots` are read-only; `send`, `retry`, and `stop` are durable, idempotent controls. The command dispatches without interrupting an active ordinary agent turn. See [Control Group Chats from mobile messaging](/user-guide/bot-mode#control-group-chats-from-mobile-messaging). | | `/platform [name]` | Operate a running gateway platform right from chat. `/platform list` shows every adapter and its state (running, paused-by-breaker, manually-paused); `/platform pause ` stops dispatching new messages to that adapter without unloading it; `/platform resume ` re-enables it and clears a tripped circuit breaker once the upstream is healthy. | | `/reload-mcp` (alias: `/reload_mcp`) | Reload MCP servers from config. | | `/verbose` | Cycle tool progress display. **Off by default on messaging** — enable with `display.tool_progress_command: true` in `config.yaml`. | @@ -309,7 +310,7 @@ The messaging gateway supports the following built-in commands inside Telegram, - `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/focus` and `/verbose` share one suppression path (`display.tool_progress`), so they can never contradict each other: `/focus on` pins tool progress to `off` and stashes your mode under `display.focus_saved_tool_progress`; `/focus off` restores it; cycling `/verbose` while focus is on takes the mode back and clears the focus badge. Focus view is display-only — it never changes conversation history, the system prompt, or anything sent to the model, so it has zero prompt-cache impact. -- `/sethome`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands. +- `/sethome`, `/restart`, `/approve`, `/deny`, `/topic`, `/group`, `/platform`, and `/commands` are **messaging-only** commands. - `/status`, `/egress`, `/version`, `/whoami`, `/bg`, `/btw`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/approvals`, `/busy`, `/footer`, `/curator`, `/kanban`, `/topup`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. - In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume ` for saved or closed transcripts. diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 0e50502a3f0b3..7bed08db1f241 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -125,6 +125,42 @@ Every gateway you register in **Settings → Connections** — local, remote URL - **`message_agent` reaches them directly.** A Bot on your laptop messages the cloud agent with `message_agent(target="moxie", …)` exactly like a local teammate. If the same handle exists on several machines, disambiguate with `target="moxie@"` (the tool's error tells the Bot the exact forms). Delivery rides the Desktop: the sending gateway queues the message, the Desktop relays it to the target connection's own gateway, the target Bot runs a turn in its canonical Bot Chat, and the reply comes back to the sender as the same background completion notification local DMs use. - **The Desktop is the courier.** Cross-connection delivery works while a Desktop that knows both connections is running (it holds the sockets and the credentials — gateways never see each other's auth). If the Desktop is closed mid-delivery, the sender's Bot is told the reply didn't arrive rather than left hanging. For always-on machine-to-machine messaging with no Desktop in the loop, register a peer (`hermes peer`, below) — the two routes coexist. +### Control Group Chats from mobile messaging + +From an authorized private owner chat on a connected messaging platform, use +`/group` to open the same Bot Group Chats without keeping Hermes Desktop in the +foreground: + +```text +/group +/group 2 +/group 2 bots +/group 2 bot @handle +/group 2 send Review the launch checklist +/group 2 retry +/group 2 stop +``` + +Telegram, Discord, and Matrix can show native room and participant pickers. +Other messaging clients receive the same controls as bounded text. Group Chat +numbers stay stable for the lifetime of the room and are not reused after +Disband. + +These commands are gateway controls, not ordinary agent prompts. A Send is +recorded durably and acknowledged as queued or saved; the Group Chat driver runs +Bot turns separately. Sending `/group ...` while your ordinary Hermes chat is +working does not interrupt that turn or wait on its conversation lock. + +Hosted Group Chats continue while Desktop is closed as long as their authority +gateway and required Bot routes remain reachable. Classic compatibility rooms +save commands for the exact owning Desktop and say so explicitly until it is +online. Stop is two-phase: `Stop requested` means cancellation is in progress, +not that every active Bot turn has already terminated. + +This control surface is owner-only by default. It does not bind arbitrary public +channels to a Group Chat, mirror every room message into a native channel, or +accept bot/webhook-authored control traffic. + ### Bot-initiated DMs across machines (`hermes peer`) Bots on one machine can message Bots on **another machine's gateway** without any desktop in the loop. Register the other gateway as a *peer* (its API server URL + `API_SERVER_KEY`): From 9b1fc359476c7cf845fa894a9298cb85d31f6bf8 Mon Sep 17 00:00:00 2001 From: "Axl Ibiza, MBA" Date: Tue, 1 Sep 2026 08:11:32 -0500 Subject: [PATCH 12/16] test(bot-mode): skip POSIX mailbox mode check on Windows --- tests/gateway/test_desktop_room_mailbox.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/gateway/test_desktop_room_mailbox.py b/tests/gateway/test_desktop_room_mailbox.py index 96f9fcfae3af8..b2fa22f7d6a30 100644 --- a/tests/gateway/test_desktop_room_mailbox.py +++ b/tests/gateway/test_desktop_room_mailbox.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import sys import pytest @@ -55,6 +56,10 @@ def test_enqueue_is_idempotent_and_rejects_key_reuse(tmp_path): ) +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="POSIX mode bits are not enforced on Windows", +) def test_enqueue_moves_the_cross_process_pending_signal(tmp_path): db = tmp_path / "desktop_room_mailbox.db" signal = mailbox.pending_signal_path(db) From 0b5404f52e0bc31bc5cba9e7df9949ddd88356a4 Mon Sep 17 00:00:00 2001 From: "Axl Ibiza, MBA" Date: Tue, 1 Sep 2026 08:37:44 -0500 Subject: [PATCH 13/16] test(bot-mode): gate POSIX room secret mode on Windows --- tests/gateway/test_hosted_room_peer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/gateway/test_hosted_room_peer.py b/tests/gateway/test_hosted_room_peer.py index 0cbba20efb53e..f48da59900d73 100644 --- a/tests/gateway/test_hosted_room_peer.py +++ b/tests/gateway/test_hosted_room_peer.py @@ -5,6 +5,7 @@ import hashlib import json import stat +import sys from concurrent.futures import ThreadPoolExecutor from pathlib import Path @@ -52,7 +53,8 @@ def test_gateway_room_grant_secret_is_private_persistent_and_not_an_api_key( secret_path = home / ".room-link-grant-secret" assert first == second assert len(first) == 32 - assert stat.S_IMODE(secret_path.stat().st_mode) == 0o600 + if not sys.platform.startswith("win"): + assert stat.S_IMODE(secret_path.stat().st_mode) == 0o600 assert secret_path.read_bytes() != first assert first != derive_room_grant_secret("gateway-api-key-1234567890") From b6440c39d38e5ba7fe37f0c6d89711008afa801b Mon Sep 17 00:00:00 2001 From: "Axl Ibiza, MBA" Date: Tue, 1 Sep 2026 08:45:00 -0500 Subject: [PATCH 14/16] test(gateway): prove group control bypasses active sessions --- tests/gateway/test_command_bypass_active_session.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py index 20b91c25e1a71..308d8af441f65 100644 --- a/tests/gateway/test_command_bypass_active_session.py +++ b/tests/gateway/test_command_bypass_active_session.py @@ -316,6 +316,7 @@ class TestAllResolvableCommandsBypassGuard: ("/usage", "usage"), ("/reload-mcp", "reload-mcp"), ("/sethome", "sethome"), + ("/group 1 send hello", "group"), ], ) @pytest.mark.asyncio @@ -342,7 +343,7 @@ def test_should_bypass_returns_true_for_every_registered_command(self): for cmd in ( "model", "reasoning", "personality", "voice", "insights", "title", "resume", "retry", "undo", "compress", "usage", - "reload-mcp", "sethome", "reset", + "reload-mcp", "sethome", "group", "reset", ): assert should_bypass_active_session(cmd) is True, ( f"/{cmd} must bypass the active-session guard" From e3ec744b16c591f4e3fda49188e53466bd1fb172 Mon Sep 17 00:00:00 2001 From: "Axl Ibiza, MBA" Date: Wed, 2 Sep 2026 09:03:49 -0500 Subject: [PATCH 15/16] refactor(gateway): shard lease-wait status ownership --- gateway/run.py | 29 ++------------ run_agent.py | 18 ++------- session_turn_lease.py | 38 +++++++++++++++++++ .../test_session_lease_wait_refresh.py | 28 +++++++------- 4 files changed, 60 insertions(+), 53 deletions(-) create mode 100644 session_turn_lease.py diff --git a/gateway/run.py b/gateway/run.py index bd8ff8b9030cf..0f85cc79ba3cd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -68,6 +68,8 @@ ) from hermes_cli.config import _is_ssh_remote_tilde_cwd, cfg_get from hermes_cli.fallback_config import get_fallback_chain +from session_turn_lease import is_session_turn_lease_wait_refresh as _is_lease_refresh +from session_turn_lease import session_turn_lease_refresh_re as _session_turn_lease_refresh_re # --- Agent cache tuning --------------------------------------------------- # Bounds the per-session AIAgent cache to prevent unbounded growth in @@ -1173,27 +1175,6 @@ def _clarify_send_then_wait(fut, *, clarify_id: str, session_key: str, clarify_m # Timeout or session-boundary cancellation return f"[user did not respond within {int(timeout / 60)}m]" return response -_SESSION_TURN_LEASE_REFRESH_RE = None - - -def _session_turn_lease_refresh_re(): - """Compile-once matcher for run_agent's periodic lease-wait refresh. - - Derived from the SAME template constant the emit site formats - (SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE, #89166) — never - re-inlined wording, same convention as _COMPRESSION_PROGRESS_STATUS_RE - (#69550). The import stays lazy because gateway/run.py never imports - run_agent at module scope. - """ - global _SESSION_TURN_LEASE_REFRESH_RE - if _SESSION_TURN_LEASE_REFRESH_RE is None: - from run_agent import SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE - - _SESSION_TURN_LEASE_REFRESH_RE = re.compile( - _status_template_to_regex(SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE), - re.IGNORECASE, - ) - return _SESSION_TURN_LEASE_REFRESH_RE def _should_suppress_lease_wait_refresh(adapter, message: str) -> bool: @@ -1207,10 +1188,8 @@ def _should_suppress_lease_wait_refresh(adapter, message: str) -> bool: chat message (#89166). Suppress the refresh there; the initial notice and the lease-timeout warning use different wording and are always delivered. """ - if callable(getattr(adapter, "send_or_update_status", None)): - return False - return bool(_session_turn_lease_refresh_re().search(str(message or ""))) - + can_update = callable(getattr(adapter, "send_or_update_status", None)) + return not can_update and _is_lease_refresh(message) def _resolve_progress_thread_id( diff --git a/run_agent.py b/run_agent.py index c24d23e8a1ba8..4f0948eb6342c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -64,6 +64,8 @@ from types import SimpleNamespace from hermes_constants import get_hermes_home +from session_turn_lease import SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE # noqa: F401 +from session_turn_lease import format_session_turn_lease_wait_refresh as _lease_refresh def _launch_cwd_for_session(source: str) -> Optional[str]: @@ -310,16 +312,6 @@ def _is_ephemeral_scaffolding(msg: Any) -> bool: _MAX_TOOL_WORKERS = 8 -# Wording of the periodic refresh emitted while a cross-process session turn -# lease is held elsewhere (``_on_session_turn_lease_wait``). The refresh only -# makes sense on surfaces that can update the initial wait notice in place; -# gateway/run.py derives its no-in-place-update suppression matcher from this -# constant (#89166) — never re-inline the wording at either site. -SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE = ( - "⏳ Still waiting for the other Hermes process on " - "this session ({elapsed_seconds}s)..." -) - # Intrinsic marker stamped on a message dict once it has been written to the # SQLite session store. Used by ``_flush_messages_to_session_db`` to decide # what is already durable. An object-identity (``id(msg)``) dedup set cannot be @@ -9384,11 +9376,7 @@ def _on_session_turn_lease_wait(elapsed: float) -> None: "waiting for it to finish before starting your turn..." ) else: - self._emit_status( - SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE.format( - elapsed_seconds=int(elapsed) - ) - ) + self._emit_status(_lease_refresh(int(elapsed))) if not _turn_db.acquire_session_turn_lease( session_id, diff --git a/session_turn_lease.py b/session_turn_lease.py new file mode 100644 index 0000000000000..67e245ee7bc82 --- /dev/null +++ b/session_turn_lease.py @@ -0,0 +1,38 @@ +"""Shared wording and recognition for session turn-lease wait refreshes.""" + +import re + + +SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE = ( + "⏳ Still waiting for the other Hermes process on " + "this session ({elapsed_seconds}s)..." +) + +_SESSION_TURN_LEASE_REFRESH_RE = None + + +def session_turn_lease_refresh_re(): + """Compile the matcher from the same template used by the emit site.""" + global _SESSION_TURN_LEASE_REFRESH_RE + if _SESSION_TURN_LEASE_REFRESH_RE is None: + parts = re.split( + r"\{[^{}]*\}", + SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE, + ) + _SESSION_TURN_LEASE_REFRESH_RE = re.compile( + r"[\d,]+".join(re.escape(part) for part in parts), + re.IGNORECASE, + ) + return _SESSION_TURN_LEASE_REFRESH_RE + + +def format_session_turn_lease_wait_refresh(elapsed_seconds: int) -> str: + """Format one periodic session turn-lease wait refresh.""" + return SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE.format( + elapsed_seconds=elapsed_seconds + ) + + +def is_session_turn_lease_wait_refresh(message: object) -> bool: + """Return whether ``message`` is the periodic lease-wait refresh.""" + return bool(session_turn_lease_refresh_re().search(str(message or ""))) diff --git a/tests/gateway/test_session_lease_wait_refresh.py b/tests/gateway/test_session_lease_wait_refresh.py index 30619d19c4a11..9e9ad9bb105e4 100644 --- a/tests/gateway/test_session_lease_wait_refresh.py +++ b/tests/gateway/test_session_lease_wait_refresh.py @@ -20,9 +20,15 @@ from gateway.config import Platform from gateway.run import ( _prepare_gateway_status_message, + _session_turn_lease_refresh_re, _should_suppress_lease_wait_refresh, ) from run_agent import SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE +from session_turn_lease import ( + SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE as OWNER_TEMPLATE, + format_session_turn_lease_wait_refresh, + session_turn_lease_refresh_re, +) INITIAL_WAIT_NOTICE = ( "⏳ Another Hermes process is using this session; " @@ -51,33 +57,29 @@ def _adapter_with_status_update(): def _prepared_refresh(elapsed_seconds: int) -> str: - message = SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE.format( - elapsed_seconds=elapsed_seconds - ) - prepared = _prepare_gateway_status_message( - Platform.WECOM, "lifecycle", message - ) + message = format_session_turn_lease_wait_refresh(elapsed_seconds) + prepared = _prepare_gateway_status_message(Platform.WECOM, "lifecycle", message) assert prepared is not None, "refresh must survive the noise filter first" return prepared +def test_refresh_owner_preserves_original_import_seams(): + assert SESSION_TURN_LEASE_WAIT_REFRESH_STATUS_TEMPLATE is OWNER_TEMPLATE + assert _session_turn_lease_refresh_re is session_turn_lease_refresh_re + + def test_refresh_suppressed_on_adapter_without_in_place_updates(): adapter = _adapter_without_status_update() for elapsed in (15, 30, 105, 120): assert ( - _should_suppress_lease_wait_refresh( - adapter, _prepared_refresh(elapsed) - ) + _should_suppress_lease_wait_refresh(adapter, _prepared_refresh(elapsed)) is True ) def test_refresh_delivered_when_adapter_updates_in_place(): adapter = _adapter_with_status_update() - assert ( - _should_suppress_lease_wait_refresh(adapter, _prepared_refresh(15)) - is False - ) + assert _should_suppress_lease_wait_refresh(adapter, _prepared_refresh(15)) is False def test_initial_wait_notice_never_suppressed(): From 63fd45b6245b3430db17fdeb409f38932eb3414d Mon Sep 17 00:00:00 2001 From: "Axl Ibiza, MBA" Date: Wed, 2 Sep 2026 09:25:39 -0500 Subject: [PATCH 16/16] test(tui): make change watcher fixtures encoding-explicit --- tests/tui_gateway/test_change_watcher.py | 60 +++++++++++++++--------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/tests/tui_gateway/test_change_watcher.py b/tests/tui_gateway/test_change_watcher.py index 9f484bec4a067..db00f05550d04 100644 --- a/tests/tui_gateway/test_change_watcher.py +++ b/tests/tui_gateway/test_change_watcher.py @@ -17,7 +17,7 @@ @pytest.fixture() def watcher_home(tmp_path, monkeypatch): - (tmp_path / "config.yaml").write_text("display: {}\n") + (tmp_path / "config.yaml").write_text("display: {}\n", encoding="utf-8") (tmp_path / "cron").mkdir() monkeypatch.setattr(server, "_hermes_home", str(tmp_path)) @@ -36,8 +36,8 @@ def watcher_home(tmp_path, monkeypatch): def test_first_sighting_seeds_without_broadcasting(watcher_home): home, events = watcher_home - (home / "cron" / "jobs.json").write_text("[]") - (home / "state.db").write_text("x") + (home / "cron" / "jobs.json").write_text("[]", encoding="utf-8") + (home / "state.db").write_text("x", encoding="utf-8") server._broadcast_watched_changes(now=0.0) @@ -48,7 +48,7 @@ def test_cron_jobs_file_move_broadcasts_cron_changed(watcher_home): home, events = watcher_home server._broadcast_watched_changes(now=0.0) - (home / "cron" / "jobs.json").write_text("[]") + (home / "cron" / "jobs.json").write_text("[]", encoding="utf-8") server._broadcast_watched_changes(now=10.0) assert ("cron.changed", {}) in events @@ -58,7 +58,7 @@ def test_state_db_move_broadcasts_sessions_changed(watcher_home): home, events = watcher_home server._broadcast_watched_changes(now=0.0) - (home / "state.db").write_text("x") + (home / "state.db").write_text("x", encoding="utf-8") server._broadcast_watched_changes(now=10.0) assert ("sessions.changed", {}) in events @@ -75,7 +75,7 @@ def test_served_profile_store_move_broadcasts_sessions_changed(watcher_home, mon assert server._profile_home("bot") == bot_home server._broadcast_watched_changes(now=0.0) - (bot_home / "state.db").write_text("x") + (bot_home / "state.db").write_text("x", encoding="utf-8") server._broadcast_watched_changes(now=10.0) assert ("sessions.changed", {}) in events @@ -85,7 +85,7 @@ def test_gateway_state_move_broadcasts_platforms_changed(watcher_home): home, events = watcher_home server._broadcast_watched_changes(now=0.0) - (home / "gateway_state.json").write_text('{"platforms": {}}') + (home / "gateway_state.json").write_text('{"platforms": {}}', encoding="utf-8") server._broadcast_watched_changes(now=10.0) assert ("platforms.changed", {}) in events @@ -104,7 +104,9 @@ def test_pending_pairing_request_broadcasts_pairing_changed(watcher_home): store.mkdir(parents=True) server._broadcast_watched_changes(now=0.0) - (store / "telegram-pending.json").write_text('{"abc": {"user_id": "1"}}') + (store / "telegram-pending.json").write_text( + '{"abc": {"user_id": "1"}}', encoding="utf-8" + ) server._broadcast_watched_changes(now=10.0) assert ("pairing.changed", {}) in events @@ -118,7 +120,9 @@ def test_pairing_signal_follows_a_profile_store(watcher_home): store.mkdir(parents=True) server._broadcast_watched_changes(now=0.0) - (store / "telegram-approved.json").write_text('{"u1": {"user_id": "u1"}}') + (store / "telegram-approved.json").write_text( + '{"u1": {"user_id": "u1"}}', encoding="utf-8" + ) server._broadcast_watched_changes(now=10.0) assert ("pairing.changed", {}) in events @@ -130,10 +134,10 @@ def test_rate_limit_churn_does_not_broadcast_pairing_changed(watcher_home): home, events = watcher_home store = home / "platforms" / "pairing" store.mkdir(parents=True) - (store / "telegram-pending.json").write_text("{}") + (store / "telegram-pending.json").write_text("{}", encoding="utf-8") server._broadcast_watched_changes(now=0.0) - (store / "_rate_limits.json").write_text('{"telegram:1": 123}') + (store / "_rate_limits.json").write_text('{"telegram:1": 123}', encoding="utf-8") server._broadcast_watched_changes(now=10.0) assert ("pairing.changed", {}) not in events @@ -143,13 +147,13 @@ def test_sessions_floor_coalesces_burst_but_keeps_trailing_edge(watcher_home): home, events = watcher_home server._broadcast_watched_changes(now=0.0) - (home / "state.db").write_text("x") + (home / "state.db").write_text("x", encoding="utf-8") server._broadcast_watched_changes(now=10.0) events.clear() # A second write lands inside the 2s floor: no broadcast yet… time.sleep(0.02) - (home / "state.db").write_text("xy") + (home / "state.db").write_text("xy", encoding="utf-8") server._broadcast_watched_changes(now=11.0) assert events == [] @@ -163,7 +167,10 @@ def test_pet_sig_stays_off_without_a_renderable_pet(watcher_home): server._broadcast_watched_changes(now=0.0) # Config flips enabled but no pet exists on disk → signature stays ("off",). - (home / "config.yaml").write_text("display:\n pet:\n enabled: true\n slug: boba\n") + (home / "config.yaml").write_text( + "display:\n pet:\n enabled: true\n slug: boba\n", + encoding="utf-8", + ) server._cfg_cache = None server._broadcast_watched_changes(now=10.0) @@ -172,12 +179,15 @@ def test_pet_sig_stays_off_without_a_renderable_pet(watcher_home): def test_renderable_pet_broadcasts_meta_payload(watcher_home, monkeypatch): home, events = watcher_home - (home / "config.yaml").write_text("display:\n pet:\n enabled: true\n slug: boba\n") + (home / "config.yaml").write_text( + "display:\n pet:\n enabled: true\n slug: boba\n", + encoding="utf-8", + ) server._cfg_cache = None server._broadcast_watched_changes(now=0.0) sheet = home / "sheet.png" - sheet.write_text("png") + sheet.write_text("png", encoding="utf-8") class FakePet: slug = "boba" @@ -205,7 +215,9 @@ def test_enqueued_envelope_broadcasts_outbox_pending(watcher_home): outbox.mkdir(parents=True) server._broadcast_watched_changes(now=0.0) - (outbox / ("a" * 32 + ".json")).write_text('{"id": "' + "a" * 32 + '"}') + (outbox / ("a" * 32 + ".json")).write_text( + '{"id": "' + "a" * 32 + '"}', encoding="utf-8" + ) server._broadcast_watched_changes(now=10.0) assert ("bot_relay.outbox.pending", {}) in events @@ -218,7 +230,7 @@ def test_drained_outbox_does_not_rebroadcast_pending(watcher_home): outbox = home / "bot_relay" / "outbox" outbox.mkdir(parents=True) envelope = outbox / ("b" * 32 + ".json") - envelope.write_text("{}") + envelope.write_text("{}", encoding="utf-8") server._broadcast_watched_changes(now=0.0) envelope.unlink() # the Desktop drained it @@ -235,9 +247,11 @@ def test_new_envelope_after_drain_fires_pending_again(watcher_home): outbox = home / "bot_relay" / "outbox" outbox.mkdir(parents=True) first = outbox / ("c" * 32 + ".json") - first.write_text("{}") + first.write_text("{}", encoding="utf-8") server._broadcast_watched_changes(now=0.0) - first.write_text("{}") # make the first sighting a change, not a seed + first.write_text( + "{}", encoding="utf-8" + ) # make the first sighting a change, not a seed bump_ns = first.stat().st_mtime_ns + 1_000_000 os.utime(first, ns=(bump_ns, bump_ns)) # strictly newer, FS-independent server._broadcast_watched_changes(now=10.0) @@ -246,7 +260,7 @@ def test_new_envelope_after_drain_fires_pending_again(watcher_home): server._broadcast_watched_changes(now=20.0) second = outbox / ("d" * 32 + ".json") - second.write_text("{}") + second.write_text("{}", encoding="utf-8") newer_ns = bump_ns + 1_000_000 # strictly beyond the watermark os.utime(second, ns=(newer_ns, newer_ns)) server._broadcast_watched_changes(now=30.0) @@ -262,7 +276,7 @@ def test_desktop_room_command_signal_broadcasts_pending(watcher_home): signal = home / "desktop_room_mailbox.pending" server._broadcast_watched_changes(now=0.0) - signal.write_text("1") + signal.write_text("1", encoding="utf-8") server._broadcast_watched_changes(now=10.0) assert ("desktop_rooms.commands.pending", {}) in events @@ -285,7 +299,7 @@ def test_broken_probe_never_kills_the_pass(watcher_home, monkeypatch): "cron.changed", (1.0, lambda: (_ for _ in ()).throw(RuntimeError("boom")), lambda: {}), ) - (home / "state.db").write_text("x") + (home / "state.db").write_text("x", encoding="utf-8") server._broadcast_watched_changes(now=10.0) # The broken cron probe is skipped; sessions still broadcasts.