Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
"""
Hermes Gateway - Multi-platform messaging integration.
This module provides a unified gateway for connecting the Hermes agent
to various messaging platforms (Telegram, Discord, WhatsApp, Weixin, and more) with:
- Session management (persistent conversations with reset policies)
- Dynamic context injection (agent knows where messages come from)
- Delivery routing (cron job outputs to appropriate channels)
- Platform-specific toolsets (different capabilities per platform)
"""
from .config import GatewayConfig, PlatformConfig, HomeChannel, load_gateway_config
from .session import (
SessionContext,
SessionStore,
SessionResetPolicy,
build_session_context_prompt,
)
from .delivery import DeliveryRouter, DeliveryTarget
__all__ = [
# Config
"GatewayConfig",
"PlatformConfig",
"HomeChannel",
"load_gateway_config",
# Session
"SessionContext",
"SessionStore",
"SessionResetPolicy",
"build_session_context_prompt",
# Delivery
"DeliveryRouter",
"DeliveryTarget",
]
+310
View File
@@ -0,0 +1,310 @@
"""Memory-pressure bounds for the gateway's per-session AIAgent cache.
The gateway caches one ``AIAgent`` per session so a long-lived conversation
reuses its prompt prefix instead of rebuilding the system prompt every turn.
Each cached agent also pins ``_session_messages`` — the full live transcript,
tool outputs included, which is tens of MB on a tool-heavy session.
``gateway/run.py`` bounds that cache two ways, and both are blind to how much
memory it actually holds:
* the LRU cap counts *entries*, not bytes, and 128 warm transcripts is
several GB;
* the idle TTL only sheds agents that went quiet for an hour, and it
deliberately defers eviction for a finalizable session that has not expired
yet, so a busy gateway hoards every transcript all day.
This module supplies the missing signal: the process's own anonymous RSS,
compared against a budget derived from the cgroup limit the gateway actually
runs under. ``GatewayRunner._sweep_agent_cache_under_pressure`` uses it to
shed LRU transcripts through the existing soft-eviction path, which rebuilds
from the persisted session on the next turn (#80764).
Everything here is pure or read-only so it can be tested without a gateway.
Config lives under ``agent.agent_cache`` in ``config.yaml``.
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Iterable, List, Optional, Tuple
# Fraction of the resolved memory limit at which we start shedding
# transcripts. Deliberately well under the limit: on the reported incident
# the gateway hit cgroup ``memory.high`` throttling with swap full, and a
# SIGTERM flush from there could not finish inside systemd's stop timeout.
# Eviction has to happen while the process still has room to breathe.
_AUTO_BUDGET_FRACTION = 0.65
# Below this a "budget" is noise — small containers would evict on every pass
# and never keep a warm prefix.
_AUTO_BUDGET_FLOOR_MB = 512
_DEFAULT_MAX_EVICTIONS_PER_PASS = 16
# Never let a pressure pass touch the hottest sessions: they are the ones
# whose prompt cache is worth the most, and shedding them just moves the cost
# to the next turn instead of removing it.
_DEFAULT_PROTECT_RECENT = 8
_BYTES_PER_MB = 1024 * 1024
@dataclass(frozen=True)
class AgentCacheBounds:
"""Operator-facing bounds for the per-session agent cache.
``max_size`` and ``idle_ttl_secs`` are ``None`` when the operator did not
set them, so ``gateway/run.py`` keeps using its module-level defaults.
``memory_high_mb`` is ``None`` when pressure eviction is switched off.
"""
max_size: Optional[int] = None
idle_ttl_secs: Optional[float] = None
memory_high_mb: Optional[int] = None
max_evictions_per_pass: int = _DEFAULT_MAX_EVICTIONS_PER_PASS
protect_recent: int = _DEFAULT_PROTECT_RECENT
def _positive_int(value: Any) -> Optional[int]:
if isinstance(value, bool) or value is None:
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _positive_float(value: Any) -> Optional[float]:
if isinstance(value, bool) or value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _cgroup_limit_bytes() -> Optional[int]:
"""Return the memory limit this process runs under, if it is cgroup-capped.
Prefers cgroup v2 ``memory.high`` (the throttling point — passing it is
what stalled the reported shutdown) over ``memory.max``, and falls back to
cgroup v1. ``max`` / absurd sentinel values mean "unlimited".
Checks the process's *own* cgroup first (where a systemd unit's
``MemoryHigh=``/``MemoryMax=`` actually lands — the root files read
``max`` on those deployments), then walks up to the root for
container-style limits.
"""
if sys.platform != "linux":
return None
candidates: list[str] = []
try:
from gateway.cgroup_cleanup import _own_cgroup_path
own = _own_cgroup_path()
except Exception:
own = None
if own and own != "/":
candidates.extend(
(
f"/sys/fs/cgroup{own}/memory.high",
f"/sys/fs/cgroup{own}/memory.max",
)
)
candidates.extend(
(
"/sys/fs/cgroup/memory.high",
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
)
)
for candidate in candidates:
try:
raw = Path(candidate).read_text(encoding="utf-8").strip()
except OSError:
continue
if not raw or raw == "max":
continue
try:
limit = int(raw)
except ValueError:
continue
# cgroup v1 reports "unlimited" as a near-2^63 sentinel.
if limit <= 0 or limit >= (1 << 62):
continue
return limit
return None
def _total_memory_bytes() -> Optional[int]:
try:
return int(os.sysconf("SC_PAGE_SIZE")) * int(os.sysconf("SC_PHYS_PAGES"))
except (OSError, ValueError, AttributeError):
pass
try:
import psutil # type: ignore
return int(psutil.virtual_memory().total)
except Exception:
return None
def resolve_memory_high_mb(setting: Any) -> Optional[int]:
"""Resolve the ``memory_high_mb`` setting into an absolute MB budget.
``"auto"`` derives a budget from the cgroup limit the gateway runs under
(or total RAM when uncapped), which is what makes this fix work out of the
box on the containerised/systemd deployments where the leak bites. A
positive number is taken literally; anything falsy disables the pass.
"""
if isinstance(setting, str):
normalized = setting.strip().lower()
if normalized != "auto":
return (
None
if normalized in ("", "off", "none", "false", "disabled")
else _positive_int(normalized)
)
elif isinstance(setting, bool):
if not setting:
return None
else:
return _positive_int(setting)
limit = _cgroup_limit_bytes() or _total_memory_bytes()
if not limit:
return None
budget = int(limit * _AUTO_BUDGET_FRACTION / _BYTES_PER_MB)
return budget if budget >= _AUTO_BUDGET_FLOOR_MB else None
def resolve_agent_cache_bounds(config: Any) -> AgentCacheBounds:
"""Read ``agent.agent_cache`` out of a raw config mapping.
Reads the *raw* user config (the gateway's loader does not deep-merge
``DEFAULT_CONFIG``), so an absent key stays absent and the caller can tell
"operator chose 128" from "operator said nothing".
"""
section: Any = None
if isinstance(config, dict):
agent_cfg = config.get("agent")
if isinstance(agent_cfg, dict):
section = agent_cfg.get("agent_cache")
if not isinstance(section, dict):
section = {}
max_evictions = _positive_int(section.get("max_evictions_per_pass"))
protect_recent = section.get("protect_recent")
protect_parsed = _positive_int(protect_recent)
if (
protect_parsed is None
and isinstance(protect_recent, int)
and not isinstance(protect_recent, bool)
and protect_recent == 0
):
# 0 means "shed anything" — distinct from unset. The isinstance
# guards keep `protect_recent: false` (a YAML-typo bool, False == 0)
# on the default instead of silently disabling MRU protection.
protect_parsed = 0
return AgentCacheBounds(
max_size=_positive_int(section.get("max_size")),
idle_ttl_secs=_positive_float(section.get("idle_ttl_secs")),
memory_high_mb=resolve_memory_high_mb(section.get("memory_high_mb", "auto")),
max_evictions_per_pass=(
max_evictions if max_evictions is not None else _DEFAULT_MAX_EVICTIONS_PER_PASS
),
protect_recent=(
protect_parsed if protect_parsed is not None else _DEFAULT_PROTECT_RECENT
),
)
def read_anon_rss_mb() -> Optional[int]:
"""Return the process's anonymous resident memory in MB, or None.
Anonymous pages are the ones cached transcripts live in — the reported
incident measured 11.0 GB of anon out of 11.0 GB total, so file-backed
pages are noise here. ``collect_memory_snapshot`` already reads
``/proc/self/status`` without a dependency; psutil covers everything else,
where only total RSS is available.
"""
try:
from hermes_cli.mem_trim import collect_memory_snapshot
snapshot = collect_memory_snapshot()
anon_kib = snapshot.get("rss_anon_kib")
if isinstance(anon_kib, int) and anon_kib > 0:
return anon_kib // 1024
rss_kib = snapshot.get("rss_kib")
if isinstance(rss_kib, int) and rss_kib > 0:
return rss_kib // 1024
except Exception:
pass
try:
import psutil # type: ignore
return int(psutil.Process(os.getpid()).memory_info().rss / _BYTES_PER_MB)
except Exception:
return None
def transcript_persistence_caught_up(agent: Any) -> bool:
"""True when the agent's live transcript is fully on disk.
Soft eviction drops ``_session_messages`` and rebuilds it from the
persisted session next turn, so it is only safe once persistence has
caught up. ``_last_flushed_db_idx`` is advanced to ``len(messages)`` by
``AIAgent._flush_messages_to_session_db`` and only on a fully successful
write — the same divergence the FTS write-corruption guard reacts to when
it preserves live history over a lagging transcript. Unknown shapes are
treated as *not* caught up: a skipped eviction costs memory, a wrong one
costs the user their conversation.
"""
messages = getattr(agent, "_session_messages", None)
if not isinstance(messages, list):
return False
flushed = getattr(agent, "_last_flushed_db_idx", None)
if not isinstance(flushed, int) or isinstance(flushed, bool):
return False
return flushed >= len(messages)
def plan_pressure_evictions(
ordered_entries: Iterable[Tuple[str, Any]],
*,
is_evictable: Callable[[str, Any], bool],
max_evictions: int,
protect_recent: int = 0,
) -> List[Tuple[str, Any]]:
"""Choose which cached sessions to shed, least-recently-used first.
``ordered_entries`` must be in LRU→MRU order (the cache is an
``OrderedDict`` kept in that order by ``move_to_end`` on every hit). The
batch is capped so one pass cannot stall the gateway tearing down clients.
``protect_recent`` is an upper bound, clamped to half the cache: a handful
of sessions can be big enough to exhaust the budget on their own (a single
tool-heavy transcript runs to hundreds of MB), and a fixed guard would
then protect the entire cache and leave the gateway climbing toward the
OOM killer with nothing it is willing to shed.
"""
entries = list(ordered_entries)
if max_evictions <= 0 or not entries:
return []
protect = min(max(protect_recent, 0), len(entries) // 2)
if protect:
entries = entries[:-protect]
plan: List[Tuple[str, Any]] = []
for key, agent in entries:
if len(plan) >= max_evictions:
break
if is_evictable(key, agent):
plan.append((key, agent))
return plan
+52
View File
@@ -0,0 +1,52 @@
status:
- still on it
- still working through it
- this is taking a bit, still running
- still checking
- not stuck, just still running
- waiting on the slow part
- still alive, still working
- letting it finish
- still processing this
- one sec, this is still going
- still making progress
- waiting for the result
- still here, checking
- the slow bit is still running
- still working on the useful answer
- no result yet, still waiting
- still running the check
- waiting on the backend bit
- this one needs a minute
- still moving
- still going, not frozen
- waiting for the useful output
- still chewing through it
- one sec, long bit is still running
- still waiting on the result
- working through the slow step
- this is taking longer than usual
- still checking, no panic
- still in progress
- letting the slow thing finish
generic:
- on it
- one sec
- checking that now
- give me a sec
- looking into it
- working through it
- checking
- still working
- one moment
- taking a look
- got it, checking
- give me a moment
- looking now
- sorting it
- checking the useful bit
- one sec, working on it
- taking a proper look
- checking the details
- working it out
- still looking
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

File diff suppressed because it is too large. Load diff
+531
View File
@@ -0,0 +1,531 @@
"""One-shot artifact transport for browser control (Gateway side).
Phase 8 Task 29: authenticated one-shot HTTPS upload/download of bounded
browser-control artifacts (screenshots, PDFs, uploads) with SHA-256
validation, exact MIME/size caps, a controlled artifact root, and TTL
cleanup. This module is the transport-neutral store core: it knows nothing
about aiohttp or the API server — the routes in
:mod:`gateway.platforms.api_server` authenticate callers and enforce rate
limits, then hand bytes to this store.
Why a store at all: the controller WebSocket is a command channel, not a
file pipe. A controller action that needs bytes (a screenshot upload, a
downloaded PDF) references an artifact by its server-minted id; the agent
side later retrieves it over HTTPS. Base64 screenshots or files in
controller WebSocket frames are therefore structurally impossible: the
frame carries only ``artifact_id`` strings, and the bytes live on disk
under a controlled root for a short TTL.
Contract (exercised by tests/gateway/test_browser_control_artifacts.py):
- **Server-minted ids, no traversal.** ``store`` assigns a fresh random hex
id; ``_artifact_path`` accepts only ``[0-9a-f]{N}`` ids and resolves them
strictly inside the root. Client-supplied filenames are metadata only
and never become filesystem paths.
- **Exact size and MIME caps.** ``store`` rejects bytes above
``max_bytes`` and any content type outside the configured allowlist
before anything touches the disk.
- **SHA-256 provenance.** Every artifact is stored with its ``sha256``,
returned in the receipt, and re-verified by ``load``/``validate`` so a
corrupted or tampered file can never be handed to a caller.
- **One-shot, scope-bound downloads.** ``load`` requires the exact scope
key the artifact was stored under and deletes the artifact atomically on
success. ``validate`` (used by the broker for "approved artifact id
only" gating) checks existence, TTL, and scope without consuming.
- **No overwrite.** Ids are random and ``store`` refuses to overwrite an
existing id (a collision is retried with a fresh id).
- **TTL cleanup.** ``prune_expired`` removes expired entries; the API
server sweeps on demand. Nothing in the store is allowed to outlive its
TTL by more than the sweep interval.
Thread-safety: the in-memory index is guarded by a lock; files are written
to a temp name and atomically renamed into place so a concurrent ``load``
never observes a partially written artifact.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import secrets
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
#: Default lifetime of a stored artifact, in clock seconds.
DEFAULT_ARTIFACT_TTL_SECONDS = 300.0
#: Default per-artifact byte cap (10 MiB).
DEFAULT_MAX_ARTIFACT_BYTES = 10 * 1024 * 1024
#: Default exact MIME allowlist. Unknown or parameterized variants are
#: rejected; clients must send the canonical registered type.
DEFAULT_ALLOWED_MIME_TYPES = frozenset(
{
"application/json",
"application/pdf",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
"text/plain",
}
)
#: Length in hex chars of a minted artifact id.
_ARTIFACT_ID_HEX = 32
_ARTIFACT_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_TEMP_SUFFIX = ".tmp"
class ArtifactError(Exception):
"""Base class for artifact store contract failures."""
class ArtifactNotFound(ArtifactError):
"""The artifact id is unknown (or already consumed)."""
class ArtifactExpired(ArtifactError):
"""The artifact outlived its TTL."""
class ArtifactTooLarge(ArtifactError):
"""The upload exceeds the configured byte cap."""
class ArtifactMimeRejected(ArtifactError):
"""The content type is outside the exact allowlist."""
class ArtifactScopeMismatch(ArtifactError):
"""The artifact exists but belongs to a different scope."""
class ArtifactChecksumMismatch(ArtifactError):
"""The stored bytes do not match the recorded SHA-256."""
class ArtifactTraversal(ArtifactError):
"""A caller-supplied id is not a valid minted artifact id."""
class ArtifactOverwrite(ArtifactError):
"""An artifact id already exists and the store refuses to overwrite it."""
@dataclass(frozen=True)
class ArtifactReceipt:
"""Provenance record returned to the caller of ``store``."""
artifact_id: str
sha256: str
size_bytes: int
content_type: str
filename: str
created_at: float
expires_at: float
ttl_seconds: float
scope_key: str
def to_dict(self, *, download_path: str = "") -> dict[str, Any]:
"""Serialize to the wire receipt (never contains file paths)."""
receipt = {
"artifact_id": self.artifact_id,
"sha256": self.sha256,
"size_bytes": self.size_bytes,
"content_type": self.content_type,
"filename": self.filename,
"created_at": self.created_at,
"expires_at": self.expires_at,
"ttl_seconds": self.ttl_seconds,
"one_shot": True,
}
if download_path:
receipt["download_path"] = download_path
return receipt
def artifact_scope_key(scope: Any) -> str:
"""Derive the stable scope key an artifact is bound to.
Only server-derived identity fields participate: principal (mandatory)
plus transport family. ``session_id`` is deliberately EXCLUDED: the HTTP
artifact routes authenticate by API key and can never resolve a server
session, while broker dispatch always carries a session-bearing
ControllerScope — including the session would make the two halves of the
intended journey (HTTP upload → broker artifact dispatch) hash to
different keys and never compose. Artifacts are therefore
principal/transport-family owned; ids are unguessable server-minted
32-hex and downloads are one-shot, so cross-session reuse within one
authenticated principal is by design. Capabilities and optional ids are
likewise excluded so a reconnect that refreshes the same controller
keeps its artifacts.
"""
principal = ""
family = ""
try:
principal = str(getattr(scope, "principal_id", "") or "")
family = str(getattr(scope, "transport_family", "") or "")
except Exception:
pass
if not principal:
# Fail closed: an artifact can only be minted for an authenticated
# principal.
raise ArtifactError("artifact scope must carry a resolved principal")
material = f"{principal}\x00{family}".encode("utf-8")
return hashlib.sha256(material).hexdigest()
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
@dataclass
class _ArtifactEntry:
receipt: ArtifactReceipt
path: Path
class ArtifactStore:
"""Thread-safe, TTL-bounded, scope-bound one-shot artifact store."""
def __init__(
self,
root: Path,
*,
ttl_seconds: float = DEFAULT_ARTIFACT_TTL_SECONDS,
max_bytes: int = DEFAULT_MAX_ARTIFACT_BYTES,
allowed_mime_types: frozenset = DEFAULT_ALLOWED_MIME_TYPES,
clock: Optional[Callable[[], float]] = None,
) -> None:
self._root = Path(root)
self._root.mkdir(parents=True, exist_ok=True)
self._ttl_seconds = max(1.0, float(ttl_seconds))
self._max_bytes = max(1, int(max_bytes))
self._allowed_mime_types = frozenset(allowed_mime_types)
self._clock = clock if clock is not None else time.time
self._lock = threading.RLock()
self._entries: dict[str, _ArtifactEntry] = {}
# Restart-safe retention: receipts live only in memory, so files
# left behind by a previous process are unreachable but would
# otherwise persist forever. Sweep every artifact-id-shaped file
# (plus stale temps) that has no index entry — at construction the
# index is empty, so anything on disk is an orphan from a dead
# process and past its advertised TTL by definition.
self._sweep_orphan_files()
def _sweep_orphan_files(self) -> int:
"""Delete on-disk artifact files with no live index entry.
Called at construction (empty index ⇒ everything on disk is an
orphan from a previous process). Only files whose names match the
server-minted 32-hex id shape or the ``*.tmp`` staging suffix are
touched; anything else in the directory is left alone.
"""
removed = 0
try:
candidates = list(self._root.iterdir())
except OSError:
return 0
with self._lock:
live = set(self._entries)
for path in candidates:
if not path.is_file():
continue
name = path.name
is_temp = name.endswith(".tmp")
if not is_temp and not _ARTIFACT_ID_RE.fullmatch(name):
continue
if not is_temp and name in live:
continue
try:
path.unlink(missing_ok=True)
removed += 1
except OSError:
continue
return removed
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def root(self) -> Path:
"""Controlled artifact root (never exposed to callers by default)."""
return self._root
@property
def ttl_seconds(self) -> float:
return self._ttl_seconds
@property
def max_bytes(self) -> int:
return self._max_bytes
@property
def allowed_mime_types(self) -> frozenset:
return self._allowed_mime_types
def store(
self,
data: bytes,
*,
filename: str,
content_type: str,
scope: Any,
) -> ArtifactReceipt:
"""Validate and store one artifact, returning its provenance receipt.
Raises :class:`ArtifactTooLarge` / :class:`ArtifactMimeRejected`
before any disk write; :class:`ArtifactError` if the scope is not a
fully resolved browser-control scope.
"""
size = len(data)
if size > self._max_bytes:
raise ArtifactTooLarge(
f"artifact is {size} bytes; cap is {self._max_bytes}"
)
normalized_type = _normalize_content_type(content_type)
if normalized_type not in self._allowed_mime_types:
raise ArtifactMimeRejected(
f"content type {content_type!r} is outside the exact allowlist"
)
scope_key = artifact_scope_key(scope)
now = self._clock()
# Mint a fresh id; retry on an astronomically unlikely collision.
while True:
artifact_id = secrets.token_hex(_ARTIFACT_ID_HEX // 2)
target = self._artifact_path(artifact_id)
with self._lock:
if artifact_id in self._entries:
continue
if target.exists():
continue
receipt = ArtifactReceipt(
artifact_id=artifact_id,
sha256=_sha256(data),
size_bytes=size,
content_type=normalized_type,
filename=_bounded_filename(filename),
created_at=now,
expires_at=now + self._ttl_seconds,
ttl_seconds=self._ttl_seconds,
scope_key=scope_key,
)
entry = _ArtifactEntry(receipt=receipt, path=target)
self._entries[artifact_id] = entry
break
# Write via temp + atomic rename so readers never observe a
# partially written artifact.
temp = target.with_name(f"{target.name}{_TEMP_SUFFIX}")
try:
with open(temp, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, target)
except Exception:
with self._lock:
self._entries.pop(artifact_id, None)
try:
temp.unlink(missing_ok=True)
except Exception:
pass
raise
return receipt
def validate(self, artifact_id: str, *, scope: Any) -> ArtifactReceipt:
"""Return the receipt when the artifact is live for ``scope``.
Used by the broker's "approved artifact id only" gate: checks
existence, TTL, and scope without consuming the artifact. Raises
the appropriate :class:`ArtifactError` subclass otherwise.
"""
return self._entry_for(artifact_id, scope=scope).receipt
def load(self, artifact_id: str, *, scope: Any) -> tuple[bytes, ArtifactReceipt]:
"""One-shot download: verify, read, checksum, then consume.
Returns ``(bytes, receipt)`` and atomically deletes the artifact so
a second ``load`` raises :class:`ArtifactNotFound`. Raises
:class:`ArtifactChecksumMismatch` (without consuming) if the file
on disk does not match the recorded SHA-256.
"""
with self._lock:
entry = self._entry_for(artifact_id, scope=scope)
path = entry.path
if not path.exists():
self._entries.pop(artifact_id, None)
raise ArtifactNotFound(f"artifact {artifact_id!r} is gone")
try:
data = path.read_bytes()
except OSError as exc:
raise ArtifactError(f"artifact read failed: {exc}") from exc
if _sha256(data) != entry.receipt.sha256:
raise ArtifactChecksumMismatch(
f"artifact {artifact_id!r} failed SHA-256 validation"
)
# Consume atomically: remove the index entry first so a
# concurrent load fails closed, then delete the file.
self._entries.pop(artifact_id, None)
try:
path.unlink(missing_ok=True)
except OSError:
logger.warning("artifact %s: file removal failed; TTL sweep will retry", artifact_id)
return data, entry.receipt
def prune_expired(self, now: Optional[float] = None) -> int:
"""Delete every artifact past its TTL; return the count removed.
Also removes orphaned temp files older than one sweep. Idempotent
and safe to call on any request or a periodic sweep.
"""
now = self._clock() if now is None else float(now)
removed = 0
with self._lock:
for artifact_id, entry in list(self._entries.items()):
if entry.receipt.expires_at <= now:
self._entries.pop(artifact_id, None)
try:
entry.path.unlink(missing_ok=True)
except OSError:
pass
removed += 1
for temp in self._root.glob(f"*{_TEMP_SUFFIX}"):
try:
if temp.stat().st_mtime <= now - self._ttl_seconds:
temp.unlink(missing_ok=True)
except OSError:
continue
return removed
def count(self) -> int:
"""Number of live (unconsumed, not-yet-pruned) artifacts."""
with self._lock:
return len(self._entries)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _entry_for(self, artifact_id: str, *, scope: Any) -> _ArtifactEntry:
path = self._artifact_path(artifact_id)
scope_key = artifact_scope_key(scope)
now = self._clock()
with self._lock:
entry = self._entries.get(artifact_id)
# Check the target's own expiry BEFORE sweeping other entries so
# an expired artifact surfaces as ArtifactExpired rather than
# silently vanishing into the sweep.
if entry is None:
self._prune_expired_locked(now)
entry = self._entries.get(artifact_id)
if entry is None:
raise ArtifactNotFound(f"unknown artifact {artifact_id!r}")
if entry.receipt.expires_at <= now:
self._entries.pop(artifact_id, None)
try:
path.unlink(missing_ok=True)
except OSError:
pass
raise ArtifactExpired(f"artifact {artifact_id!r} expired")
if entry.receipt.scope_key != scope_key:
raise ArtifactScopeMismatch(
f"artifact {artifact_id!r} is bound to a different scope"
)
return entry
def _prune_expired_locked(self, now: float) -> None:
for artifact_id, entry in list(self._entries.items()):
if entry.receipt.expires_at <= now:
self._entries.pop(artifact_id, None)
try:
entry.path.unlink(missing_ok=True)
except OSError:
pass
def _artifact_path(self, artifact_id: str) -> Path:
"""Resolve a minted id strictly inside the controlled root."""
if not isinstance(artifact_id, str) or not _ARTIFACT_ID_RE.fullmatch(artifact_id):
raise ArtifactTraversal(f"invalid artifact id {artifact_id!r}")
candidate = (self._root / artifact_id).resolve()
try:
root_resolved = self._root.resolve()
except OSError:
root_resolved = self._root.absolute()
if candidate.parent != root_resolved or candidate.name != artifact_id:
raise ArtifactTraversal(f"artifact path escapes root for {artifact_id!r}")
return candidate
def _normalize_content_type(value: str) -> str:
"""Return the canonical MIME type, or ``""`` for malformed input."""
if not isinstance(value, str):
return ""
return value.strip().split(";", 1)[0].strip().lower()
def _bounded_filename(value: str, limit: int = 160) -> str:
"""Sanitize a display-only filename; never used as a filesystem path."""
if not isinstance(value, str):
return ""
cleaned = value.strip().replace("\\", "_").replace("/", "_")
cleaned = "".join(character for character in cleaned if ord(character) >= 32)
return cleaned[:limit]
# ----------------------------------------------------------------------
# Rate limiting (route-level, per principal)
# ----------------------------------------------------------------------
class ArtifactRateLimiter:
"""Sliding-window per-key limiter for artifact routes.
The API server keys this by the authenticated principal so a single
key cannot flood the store. Injected clock makes tests deterministic.
"""
def __init__(
self,
*,
window_seconds: float = 60.0,
max_requests: int = 30,
clock: Optional[Callable[[], float]] = None,
) -> None:
self._window_seconds = max(1.0, float(window_seconds))
self._max_requests = max(1, int(max_requests))
self._clock = clock if clock is not None else time.time
self._lock = threading.Lock()
self._hits: dict[str, list[float]] = {}
def allow(self, key: str) -> bool:
"""Return True when ``key`` is under the window cap; else False."""
if not isinstance(key, str) or not key:
return False
now = self._clock()
window_start = now - self._window_seconds
with self._lock:
hits = [hit for hit in self._hits.get(key, []) if hit > window_start]
if len(hits) >= self._max_requests:
self._hits[key] = hits
return False
hits.append(now)
self._hits[key] = hits
return True
def reset(self, key: str) -> None:
"""Drop the recorded hits for ``key`` (tests/diagnostics)."""
with self._lock:
self._hits.pop(key, None)
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
"""Built-in gateway hooks that are always registered."""
+81
View File
@@ -0,0 +1,81 @@
"""SIGKILL any process left in this systemd unit's cgroup.
Runs as ``ExecStopPost=`` so it only fires after the gateway's main process
has exited. The gateway already reaps its own tool subprocesses on a clean
shutdown; this is the safety net for long-lived helpers it doesn't track
(``adb``, platform bridges, etc.) that would otherwise be orphaned in the
cgroup and block ``Restart=always`` — issue #37454.
We deliberately iterate ``cgroup.procs`` and send per-PID SIGKILLs instead
of writing ``1`` to ``cgroup.kill``: the original failure mode in #37454
was the kernel returning ``EINVAL`` on the cgroup-wide kill, while per-PID
signal delivery uses a separate code path that still works.
"""
from __future__ import annotations
import os
import re
import signal
import sys
from pathlib import Path
def _own_cgroup_path() -> str | None:
"""Return the cgroup v2 path for the calling process, or None."""
try:
text = Path("/proc/self/cgroup").read_text(encoding="utf-8")
except OSError:
return None
match = re.search(r"^0::(.+)$", text, re.MULTILINE)
if not match:
return None
return match.group(1).strip()
def _read_cgroup_pids(cgroup_path: str) -> list[int]:
procs_file = Path(f"/sys/fs/cgroup{cgroup_path}/cgroup.procs")
try:
raw = procs_file.read_text(encoding="utf-8")
except OSError:
return []
pids: list[int] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
pids.append(int(line))
except ValueError:
continue
return pids
def reap_cgroup(cgroup_path: str | None = None) -> int:
"""SIGKILL every PID in the cgroup other than the caller. Returns the count killed."""
if cgroup_path is None:
cgroup_path = _own_cgroup_path()
if not cgroup_path:
return 0
own = os.getpid()
killed = 0
for pid in _read_cgroup_pids(cgroup_path):
if pid == own:
continue
try:
os.kill(pid, signal.SIGKILL) # windows-footgun: ok — Linux-only (reads /proc, /sys/fs/cgroup; runs from a systemd unit)
killed += 1
except ProcessLookupError:
continue
except PermissionError:
continue
return killed
def main() -> int:
reap_cgroup()
return 0
if __name__ == "__main__":
sys.exit(main())
+675
View File
@@ -0,0 +1,675 @@
"""
Channel directory -- cached map of reachable channels/contacts per platform.
Built on gateway startup, refreshed periodically (every 5 min), and saved to
~/.hermes/channel_directory.json. The send_message tool reads this file for
action="list" and for resolving human-friendly channel names to numeric IDs.
"""
import asyncio
import json
import logging
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_cli.config import get_hermes_home
from utils import atomic_json_write
logger = logging.getLogger(__name__)
# Resolved lazily (see ``_directory_path``): a multiplexed gateway serves
# several profile homes from one process, so an import-time constant would pin
# every profile's directory to whichever home imported this module first.
# ``DIRECTORY_PATH`` / ``CHANNEL_ALIASES_PATH`` stay as explicit overrides
# (tests patch them); ``None`` means "resolve from the current home".
DIRECTORY_PATH: Optional[Path] = None
# Throttle window for repeated Slack channel-directory refresh failures.
# The directory rebuilds on a timer, so a persistent workspace error (e.g.
# missing scope, revoked token) would otherwise re-log the same warning on
# every refresh. Warn once per (team, error detail) per interval; repeats
# drop to DEBUG.
_SLACK_DIRECTORY_WARNING_INTERVAL_SECONDS = 3600
_slack_directory_warning_last: Dict[tuple[str, str], float] = {}
# User-maintained friendly-name overlay. The directory is fully regenerated
# from live adapters + session data on a timer, so hand-edits to
# channel_directory.json don't survive. Aliases declared here are re-applied
# on every build AND every load, giving durable human-friendly names (and
# letting you pre-name a chat before it has produced any traffic).
# Format: {"<platform>": {"<chat_id>": "<friendly name>", ...}, ...}
CHANNEL_ALIASES_PATH: Optional[Path] = None
def _directory_path() -> Path:
return DIRECTORY_PATH or get_hermes_home() / "channel_directory.json"
def _aliases_path() -> Path:
return CHANNEL_ALIASES_PATH or get_hermes_home() / "channel_aliases.json"
def _load_channel_aliases() -> Dict[str, Dict[str, str]]:
aliases_path = _aliases_path()
if not aliases_path.exists():
return {}
try:
with open(aliases_path, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _apply_channel_aliases(platforms: Dict[str, Any]) -> None:
"""Overlay friendly names onto directory entries by chat_id.
Renames matching entries in place; injects a placeholder entry for an
aliased id that hasn't been discovered yet (so a freshly-created group is
addressable by name before its first message). Mutates *platforms*.
"""
aliases = _load_channel_aliases()
for plat_name, id_map in aliases.items():
if not isinstance(id_map, dict):
continue
entries = platforms.setdefault(plat_name, [])
if not isinstance(entries, list):
continue
for chat_id, friendly in id_map.items():
if not isinstance(friendly, str) or not friendly.strip():
continue
chat_id = str(chat_id)
friendly = friendly.strip()
matched = False
for e in entries:
if isinstance(e, dict) and e.get("id") == chat_id:
e["name"] = friendly
matched = True
if not matched:
entries.append({
"id": chat_id,
"name": friendly,
"type": "group" if str(chat_id).endswith("@g.us") else "dm",
"thread_id": None,
})
def _normalize_channel_query(value: str) -> str:
return value.lstrip("#").strip().lower()
def _channel_target_name(platform_name: str, channel: Dict[str, Any]) -> str:
"""Return the human-facing target label shown to users for a channel entry."""
name = channel["name"]
if platform_name == "discord" and channel.get("guild"):
return f"#{name}"
if platform_name != "discord" and channel.get("type"):
return f"{name} ({channel['type']})"
return name
def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]:
chat_id = origin.get("chat_id")
if not chat_id:
return None
thread_id = origin.get("thread_id")
if thread_id:
return f"{chat_id}:{thread_id}"
return str(chat_id)
def _session_entry_name(origin: Dict[str, Any]) -> str:
base_name = origin.get("chat_name") or origin.get("user_name") or str(origin.get("chat_id"))
thread_id = origin.get("thread_id")
if not thread_id:
return base_name
topic_label = origin.get("chat_topic") or f"topic {thread_id}"
return f"{base_name} / {topic_label}"
def _warn_slack_directory(team_id: str, detail: str) -> None:
"""Warn once per team/error per interval for recurring Slack refresh failures."""
key = (str(team_id), str(detail))
now = time.monotonic()
last = _slack_directory_warning_last.get(key)
if last is None or now - last >= _SLACK_DIRECTORY_WARNING_INTERVAL_SECONDS:
_slack_directory_warning_last[key] = now
logger.warning(
"Channel directory: failed to list Slack channels for team %s: %s",
team_id,
detail,
)
else:
logger.debug(
"Channel directory: suppressed repeated Slack channel list failure "
"for team %s: %s",
team_id,
detail,
)
# ---------------------------------------------------------------------------
# Build / refresh
# ---------------------------------------------------------------------------
async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
"""
Build a channel directory from connected platform adapters and session data.
Returns the directory dict and writes it to the current home's
``channel_directory.json``.
"""
from gateway.config import Platform
platforms: Dict[str, List[Dict[str, str]]] = {}
for platform, adapter in adapters.items():
try:
list_channels = getattr(adapter, "list_channels", None)
if callable(list_channels):
platform_channels = await list_channels()
if platform_channels is not None:
platforms[platform.value] = _normalize_adapter_channels(platform_channels)
continue
if platform == Platform.DISCORD:
platforms["discord"] = await asyncio.to_thread(_build_discord, adapter)
elif platform == Platform.SLACK:
platforms["slack"] = await _build_slack(adapter)
except Exception as e:
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
# Platforms that don't support direct channel enumeration get session-based
# discovery automatically, but only for platforms connected in THIS gateway
# process. Historical session origins for disabled/decommissioned platforms
# must not be resurrected into the active send-target directory (stale
# targets make send_message route to platforms that can no longer deliver).
_SKIP_SESSION_DISCOVERY = frozenset({"local", "api_server", "webhook"})
adapter_platform_names = {getattr(p, "value", str(p)) for p in adapters}
for plat in Platform:
plat_name = plat.value
if (
plat_name in _SKIP_SESSION_DISCOVERY
or plat_name in platforms
or plat_name not in adapter_platform_names
):
continue
platforms[plat_name] = await asyncio.to_thread(_build_from_sessions, plat_name)
# Include plugin-registered platforms (dynamic enum members aren't in
# Platform.__members__, so the loop above misses them). Same
# connected-only rule: don't expose stale session targets for plugins
# that are not loaded.
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
if (
entry.name not in _SKIP_SESSION_DISCOVERY
and entry.name not in platforms
and entry.name in adapter_platform_names
):
platforms[entry.name] = await asyncio.to_thread(_build_from_sessions, entry.name)
except Exception:
pass
# Overlay user-maintained friendly names before persisting.
_apply_channel_aliases(platforms)
directory = {
"updated_at": datetime.now().isoformat(),
"platforms": platforms,
}
try:
await asyncio.to_thread(atomic_json_write, _directory_path(), directory)
except Exception as e:
logger.warning("Channel directory: failed to write: %s", e)
return directory
def _build_discord(adapter) -> List[Dict[str, str]]:
"""Enumerate all text channels and forum channels the Discord bot can see."""
channels = []
client = getattr(adapter, "_client", None)
if not client:
return channels
try:
import discord as _discord # noqa: F401 — SDK presence check
except ImportError:
return channels
for guild in client.guilds:
for ch in guild.text_channels:
channels.append({
"id": str(ch.id),
"name": ch.name,
"guild": guild.name,
"type": "channel",
})
# Forum channels (type 15) — creating a message auto-spawns a thread post.
forums = getattr(guild, "forum_channels", None) or []
for ch in forums:
channels.append({
"id": str(ch.id),
"name": ch.name,
"guild": guild.name,
"type": "forum",
})
# Also include DM-capable users we've interacted with is not
# feasible via guild enumeration; those come from sessions.
# Merge any DMs from session history
channels.extend(_build_from_sessions("discord"))
return channels
def _slack_api_error_code(error: Exception) -> Optional[str]:
"""Return Slack Web API error code from SlackApiError-like exceptions."""
response = getattr(error, "response", None)
if isinstance(response, dict):
value = response.get("error")
return str(value) if value else None
if response is not None:
try:
value = response.get("error")
return str(value) if value else None
except Exception:
pass
return None
def _normalize_adapter_channels(raw_channels: Any) -> List[Dict[str, Any]]:
"""Validate and dedupe channel entries returned by an adapter's
``list_channels()`` hook (see ``build_channel_directory``)."""
channels: List[Dict[str, Any]] = []
seen_ids = set()
if not isinstance(raw_channels, list):
return channels
for raw in raw_channels:
if not isinstance(raw, dict):
continue
channel_id = str(raw.get("id") or "").strip()
name = str(raw.get("name") or channel_id).strip()
if not channel_id or not name or channel_id in seen_ids:
continue
entry: Dict[str, Any] = {
"id": channel_id,
"name": name,
"type": str(raw.get("type") or "dm"),
}
if raw.get("thread_id"):
entry["thread_id"] = str(raw.get("thread_id"))
if raw.get("guild"):
entry["guild"] = str(raw.get("guild"))
channels.append(entry)
seen_ids.add(channel_id)
return channels
async def _build_slack(adapter) -> List[Dict[str, Any]]:
"""List Slack channels the bot has joined across all workspaces.
Uses ``users.conversations`` against each workspace's web client. Pulls
public + private channels the bot is a member of, then merges in DMs
discovered from session history (IMs aren't useful to enumerate
proactively). If the Slack app lacks channels:read, fall back to session
history quietly instead of logging a recurring warning every refresh.
"""
team_clients = getattr(adapter, "_team_clients", None) or {}
if not team_clients:
return await asyncio.to_thread(_build_from_sessions, "slack")
channels: List[Dict[str, Any]] = []
seen_ids: set = set()
for team_id, client in team_clients.items():
try:
cursor: Optional[str] = None
for _page in range(20): # safety cap on pagination
response = await client.users_conversations(
types="public_channel,private_channel",
exclude_archived=True,
limit=200,
cursor=cursor,
)
if not response.get("ok"):
error_code = response.get("error", "unknown")
if error_code == "missing_scope":
logger.debug(
"Channel directory: Slack team %s lacks channels:read; using session history only",
team_id,
)
else:
detail = f"users.conversations not ok: {error_code}"
_warn_slack_directory(team_id, detail)
break
for ch in response.get("channels", []):
cid = ch.get("id")
name = ch.get("name")
if not cid or not name or cid in seen_ids:
continue
seen_ids.add(cid)
channels.append({
"id": cid,
"name": name,
"type": "private" if ch.get("is_private") else "channel",
})
cursor = (response.get("response_metadata") or {}).get("next_cursor")
if not cursor:
break
except Exception as e:
if _slack_api_error_code(e) == "missing_scope":
logger.debug(
"Channel directory: Slack team %s lacks channels:read; using session history only",
team_id,
)
else:
_warn_slack_directory(team_id, str(e))
continue
# Merge in DM/group entries discovered from session history.
# Thread-qualified IDs are internal routing keys, not Slack API IDs.
def slack_lookup_id(entry_id: str) -> str:
return entry_id.split(":", 1)[0]
# Build a lookup from API-discovered channels so we can enrich session entries.
api_name_lookup = {ch["id"]: ch["name"] for ch in channels}
for entry in await asyncio.to_thread(_build_from_sessions, "slack"):
eid = entry.get("id")
if not isinstance(eid, str):
continue
if eid not in seen_ids:
# If the entry name is still a raw Slack ID (e.g. C0xxx / D0xxx),
# try to resolve it from the API lookup using the base conversation ID.
if entry.get("name", "").startswith(("C0", "D0", "G0")):
base_id = slack_lookup_id(eid)
if base_id in api_name_lookup:
entry["name"] = api_name_lookup[base_id]
channels.append(entry)
seen_ids.add(eid)
# Resolve remaining raw-ID entries (DMs, private channels not in bot scope)
# by calling conversations.info + users.info once per base conversation,
# with all base-ID lookups running concurrently.
unresolved = [ch for ch in channels if ch.get("name", "").startswith(("C0", "D0", "G0"))]
if unresolved and team_clients:
client = next(iter(team_clients.values()))
unresolved_by_base = {}
for entry in unresolved:
unresolved_by_base.setdefault(slack_lookup_id(entry["id"]), []).append(entry)
async def _resolve_base(base_id: str, entries: list) -> None:
try:
resp = await client.conversations_info(channel=base_id)
if not resp.get("ok"):
return
ch_info = resp.get("channel", {})
resolved_name = None
resolved_type = None
if ch_info.get("is_im"):
peer_user = ch_info.get("user", "")
if peer_user:
user_resp = await client.users_info(user=peer_user)
if user_resp.get("ok"):
u = user_resp["user"]
resolved_name = (
u.get("profile", {}).get("display_name")
or u.get("real_name")
or u.get("name")
)
resolved_type = "dm"
else:
resolved_name = ch_info.get("name") or ch_info.get("name_normalized")
if resolved_name:
for entry in entries:
entry["name"] = resolved_name
if resolved_type:
entry["type"] = resolved_type
except Exception as e:
logger.debug("Channel directory: failed to resolve %s: %s", base_id, e)
await asyncio.gather(
*[_resolve_base(bid, ents) for bid, ents in unresolved_by_base.items()]
)
return channels
def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
"""Pull known channels/contacts from gateway session origin data.
state.db is the primary source (#9006): gateway session rows persist
origin_json. Falls back to sessions.json for pre-migration databases.
"""
entries = _build_from_sessions_db(platform_name)
if entries:
return entries
return _build_from_sessions_json(platform_name)
def _build_from_sessions_db(platform_name: str) -> List[Dict[str, str]]:
"""Pull channels/contacts from state.db gateway session rows."""
entries: List[Dict[str, str]] = []
try:
from hermes_state import get_shared_session_db, release_or_close
db = get_shared_session_db()
try:
lister = getattr(db, "list_gateway_sessions", None)
if not callable(lister):
return []
rows = lister(platform=platform_name, active_only=False)
finally:
release_or_close(db)
seen_ids = set()
for row in rows:
origin: Dict[str, Any] = {}
if row.get("origin_json"):
try:
parsed = json.loads(row["origin_json"])
if isinstance(parsed, dict):
origin = parsed
except (TypeError, ValueError):
pass
if not origin:
origin = {
"chat_id": row.get("chat_id"),
"thread_id": row.get("thread_id"),
"chat_name": row.get("display_name"),
}
entry_id = _session_entry_id(origin)
if not entry_id or entry_id in seen_ids:
continue
seen_ids.add(entry_id)
entries.append({
"id": entry_id,
"name": _session_entry_name(origin),
"type": row.get("chat_type") or "dm",
"thread_id": origin.get("thread_id"),
})
except Exception as e:
logger.debug(
"Channel directory: state.db session read failed for %s: %s",
platform_name, e,
)
return entries
def _build_from_sessions_json(platform_name: str) -> List[Dict[str, str]]:
"""Legacy fallback: pull channels/contacts from sessions.json origin data."""
sessions_path = get_hermes_home() / "sessions" / "sessions.json"
if not sessions_path.exists():
return []
entries = []
try:
with open(sessions_path, encoding="utf-8") as f:
data = json.load(f)
seen_ids = set()
for _key, session in data.items():
# Skip documentation/metadata sentinels (keys starting with "_",
# e.g. the gateway's "_README" note) — not session entries.
if str(_key).startswith("_") or not isinstance(session, dict):
continue
origin = session.get("origin") or {}
if origin.get("platform") != platform_name:
continue
entry_id = _session_entry_id(origin)
if not entry_id or entry_id in seen_ids:
continue
seen_ids.add(entry_id)
entries.append({
"id": entry_id,
"name": _session_entry_name(origin),
"type": session.get("chat_type", "dm"),
"thread_id": origin.get("thread_id"),
})
except Exception as e:
logger.debug("Channel directory: failed to read sessions for %s: %s", platform_name, e)
return entries
# ---------------------------------------------------------------------------
# Read / resolve
# ---------------------------------------------------------------------------
def load_directory() -> Dict[str, Any]:
"""Load the cached channel directory from disk."""
directory_path = _directory_path()
if not directory_path.exists():
base = {"updated_at": None, "platforms": {}}
_apply_channel_aliases(base["platforms"])
return base
try:
with open(directory_path, encoding="utf-8") as f:
data = json.load(f)
# Re-apply aliases on read so friendly names take effect immediately,
# even between timed rebuilds and for brand-new alias entries.
_apply_channel_aliases(data.setdefault("platforms", {}))
return data
except Exception:
base = {"updated_at": None, "platforms": {}}
_apply_channel_aliases(base["platforms"])
return base
def lookup_channel_type(platform_name: str, chat_id: str) -> Optional[str]:
"""Return the channel ``type`` string (e.g. ``"channel"``, ``"forum"``) for *chat_id*, or *None* if unknown."""
directory = load_directory()
for ch in directory.get("platforms", {}).get(platform_name, []):
if ch.get("id") == chat_id:
return ch.get("type")
return None
def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
"""
Resolve a human-friendly channel name to a numeric ID.
Matching strategy (case-insensitive, first match wins):
- Discord: "bot-home", "#bot-home", "GuildName/bot-home"
- Telegram: display name or group name
- Slack: "engineering", "#engineering"
"""
directory = load_directory()
channels = directory.get("platforms", {}).get(platform_name, [])
if not channels:
return None
# 0. Exact ID match — case-sensitive, no normalization. Lets callers pass
# raw platform IDs (e.g. Slack "C0B0QV5434G") even when the format guard
# in _parse_target_ref hasn't recognized them as explicit.
raw = name.strip()
for ch in channels:
if ch.get("id") == raw:
return ch["id"]
query = _normalize_channel_query(name)
# 1. Exact name match, including the display labels shown by send_message(action="list")
for ch in channels:
if _normalize_channel_query(ch["name"]) == query:
return ch["id"]
if _normalize_channel_query(_channel_target_name(platform_name, ch)) == query:
return ch["id"]
# 2. Guild-qualified match for Discord ("GuildName/channel")
if "/" in query:
guild_part, ch_part = query.rsplit("/", 1)
for ch in channels:
guild = ch.get("guild", "").strip().lower()
if guild == guild_part and _normalize_channel_query(ch["name"]) == ch_part:
return ch["id"]
# 3. Partial prefix match (only if unambiguous)
matches = [ch for ch in channels if _normalize_channel_query(ch["name"]).startswith(query)]
if len(matches) == 1:
return matches[0]["id"]
return None
def format_directory_for_display(platforms: Optional[Dict[str, Any]] = None) -> str:
"""Format the channel directory as a human-readable list for the model.
``platforms`` overrides the on-disk directory when provided (used by
``hermes send --list`` to merge in configured-but-undiscovered
platforms). Platforms present with an empty channel list are rendered
with a "(no channels discovered yet)" hint instead of being hidden —
a configured platform is a valid send target even before discovery.
"""
if platforms is None:
directory = load_directory()
platforms = directory.get("platforms", {})
if not platforms:
return "No messaging platforms connected or no channels discovered yet."
lines = ["Available messaging targets:\n"]
for plat_name, channels in sorted(platforms.items()):
if not channels:
lines.append(f"{plat_name.title()}:")
lines.append(
f" (no channels discovered yet — send directly with "
f"{plat_name}:<chat_id>, or bare '{plat_name}' for the home channel)"
)
lines.append("")
continue
# Group Discord channels by guild
if plat_name == "discord":
guilds: Dict[str, List] = {}
dms: List = []
for ch in channels:
guild = ch.get("guild")
if guild:
guilds.setdefault(guild, []).append(ch)
else:
dms.append(ch)
for guild_name, guild_channels in sorted(guilds.items()):
lines.append(f"Discord ({guild_name}):")
for ch in sorted(guild_channels, key=lambda c: c["name"]):
lines.append(f" discord:{_channel_target_name(plat_name, ch)}")
if dms:
lines.append("Discord (DMs):")
for ch in dms:
lines.append(f" discord:{_channel_target_name(plat_name, ch)}")
lines.append("")
else:
lines.append(f"{plat_name.title()}:")
for ch in channels:
lines.append(f" {plat_name}:{_channel_target_name(plat_name, ch)}")
lines.append("")
lines.append('Use these as the "target" parameter when sending.')
lines.append('Bare platform name (e.g. "telegram") sends to home channel.')
return "\n".join(lines)
+64
View File
@@ -0,0 +1,64 @@
"""Detect when the gateway is running stale code after a hot ``git pull``.
The gateway is a single long-lived process; its ``sys.modules`` is frozen at
boot. If the checkout is updated underneath it (a manual ``git pull``, or the
window before ``hermes update``'s graceful restart fires), a first-time lazy
import on a new code path can resolve a freshly-pulled consumer module against a
stale cached dependency -> ImportError (see
``tests/test_stale_utils_module_import.py`` for the exact failure).
We snapshot the checkout revision at gateway startup and compare on demand, so
risky callers (e.g. ``/model`` switching) can refuse with a clear "restart the
gateway" message instead of crashing on a cryptic import error.
If the revision can't be read (non-git install, IO error), the boot snapshot
stays ``None`` and skew detection no-ops — it never produces a false positive.
"""
from __future__ import annotations
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
_boot_fingerprint: str | None = None
def _fingerprint() -> str | None:
"""Current checkout fingerprint, reusing the CLI's git-rev reader.
``hermes_cli.main`` is always already imported in a gateway process (it's
the entry point), so this import is free and avoids duplicating the
worktree-aware ref resolution.
"""
try:
from hermes_cli.main import _read_git_revision_fingerprint
return _read_git_revision_fingerprint(_PROJECT_ROOT)
except Exception:
return None
def record_boot_fingerprint() -> None:
"""Snapshot the checkout revision at gateway startup (idempotent)."""
global _boot_fingerprint
if _boot_fingerprint is None:
_boot_fingerprint = _fingerprint()
def _short(fingerprint: str) -> str:
"""Render a ``git:<ref>:<sha>`` fingerprint as a compact label."""
sha = fingerprint.rsplit(":", 1)[-1]
if sha and sha != "unresolved" and len(sha) > 10:
return sha[:10]
return sha or fingerprint
def detect_code_skew() -> tuple[str, str] | None:
"""Return ``(boot_rev, disk_rev)`` short labels if the checkout drifted
since boot, else ``None``."""
if _boot_fingerprint is None:
return None
current = _fingerprint()
if current is None or current == _boot_fingerprint:
return None
return _short(_boot_fingerprint), _short(current)
+2981
View File
File diff suppressed because it is too large. Load diff
+560
View File
@@ -0,0 +1,560 @@
"""
Gateway control socket — the gateway-owned local coordination surface.
Migration step 1 of the #92091 design: every other process on the machine
(the updater, `hermes serve`/dashboard, the Desktop app) currently discovers
gateway identity/state by scanning the process table and string-matching argv
or by reading ``gateway_state.json`` (which can outlive its writer). This
module gives the gateway an OWNED contract instead: a local-only socket the
gateway process creates at startup and removes on clean shutdown, answering
versioned JSON verbs. A connectable socket with a well-formed ``identify``
answer IS liveness — no PID-reuse heuristics.
v1 verbs (observation only — no behavior change for the gateway):
- ``identify`` → pid, profile label, hermes_home, code_sha/code_version
(the #91283 stamps, now queryable live), supervisor kind, served profiles,
start_time, protocol version.
- ``status`` → the live runtime-status payload (what ``gateway_state.json``
holds today, but answered by the process itself, race-free).
Transport:
- POSIX: Unix domain socket at ``$HERMES_HOME/gateway.sock``. When the home
path is too long for ``sun_path`` (~104 bytes on macOS/BSD), the socket is
bound in the system temp dir and a pointer file
``$HERMES_HOME/gateway.sock.path`` records the real location; clients
follow the pointer transparently.
- Windows: named pipe ``\\\\.\\pipe\\hermes-gateway-<home-hash>`` served via
the proactor event loop. Same trust model (per-user namespace).
Never a TCP port. Filesystem/pipe ACLs are the auth boundary — the same
trust model as ``gateway_state.json`` today.
v1 wire contract: ONE request per connection — a single JSON line in, a
single JSON line out, then the server closes. Clients must not rely on
keep-alive or pipelining. Verb handlers may touch disk (they run in an
executor server-side) but must stay fast; the client budget is small.
Consumers (``hermes update --plan`` inventory, the post-update fleet version
matrix) PREFER the socket when it answers and fall back to the existing
state-file/scan layer when it doesn't — old gateways mid-upgrade and crashed
processes keep working exactly as before. The scan layer is demoted, not
deleted.
"""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import logging
import os
import socket
import sys
import tempfile
import time
from pathlib import Path
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
CONTROL_PROTOCOL_VERSION = 1
_SOCKET_FILENAME = "gateway.sock"
_POINTER_FILENAME = "gateway.sock.path"
_IS_WINDOWS = sys.platform == "win32"
# Practical sun_path limit: 104 on macOS/BSD, 108 on Linux. Stay under the
# smaller bound with margin for the NUL terminator.
_MAX_UNIX_PATH = 100
# Requests and responses are single JSON lines. Bound them so a misbehaving
# peer can't balloon gateway memory.
_MAX_REQUEST_BYTES = 64 * 1024
_MAX_RESPONSE_BYTES = 512 * 1024
_DEFAULT_CLIENT_TIMEOUT = 2.0
# ---------------------------------------------------------------------------
# Path resolution (shared by server and client)
# ---------------------------------------------------------------------------
def _home_hash(home: Path) -> str:
canonical = os.path.normcase(str(Path(home).expanduser().resolve(strict=False)))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
def windows_pipe_name(home: Path) -> str:
"""Per-HERMES_HOME named pipe path (Windows transport)."""
return rf"\\.\pipe\hermes-gateway-{_home_hash(home)}"
def _pointer_path(home: Path) -> Path:
return Path(home) / _POINTER_FILENAME
def _default_socket_path(home: Path) -> Path:
return Path(home) / _SOCKET_FILENAME
def _fallback_socket_path(home: Path) -> Path:
"""Short temp-dir path for homes whose direct socket path exceeds sun_path.
Prefers ``tempfile.gettempdir()``; when even that yields a too-long path
(deep $TMPDIR), falls back to ``/tmp`` on POSIX. If nothing fits, the
tempdir candidate is returned anyway — bind will fail non-fatally and
consumers use the scan layer.
"""
name = f"hermes-gw-{_home_hash(home)}.sock"
candidates = [Path(tempfile.gettempdir()) / name]
if not _IS_WINDOWS:
candidates.append(Path("/tmp") / name)
for candidate in candidates:
if len(str(candidate).encode("utf-8")) <= _MAX_UNIX_PATH:
return candidate
return candidates[0]
def resolve_server_socket_path(home: Path) -> tuple[Path, Optional[Path]]:
"""Where the server should bind, plus the pointer file to write (or None).
Returns ``(bind_path, pointer_file)``. ``pointer_file`` is non-None only
when the direct in-home path is too long and the temp-dir fallback is in
use — the server must then persist the real location for clients.
"""
direct = _default_socket_path(home)
if len(str(direct).encode("utf-8")) <= _MAX_UNIX_PATH:
return direct, None
return _fallback_socket_path(home), _pointer_path(home)
def resolve_client_socket_path(home: Path) -> Optional[Path]:
"""Where a client should connect for ``home``, or None when nothing exists."""
direct = _default_socket_path(home)
if direct.exists():
return direct
pointer = _pointer_path(home)
try:
if pointer.is_file():
target = pointer.read_text(encoding="utf-8").strip()
if target:
candidate = Path(target)
if candidate.exists():
return candidate
except OSError:
pass
return None
# ---------------------------------------------------------------------------
# Default payload builders (import-light; overridable at wiring time)
# ---------------------------------------------------------------------------
def _detect_supervisor() -> str:
"""Best-effort supervisor kind for THIS process, from its own environment.
Unlike the outside-in `_detect_supervisor_for_pid` scan, this answers from
the process's own launch context — which is exactly the provenance the
#92091 design wants declared rather than inferred.
"""
env = os.environ
if env.get("INVOCATION_ID"):
return "systemd"
if sys.platform == "darwin" and (
env.get("XPC_SERVICE_NAME", "").startswith("ai.hermes")
or env.get("LAUNCHD_SOCKET")
):
return "launchd"
if env.get("HERMES_DESKTOP_MANAGED"):
return "desktop"
if "--external-supervisor" in sys.argv:
return "external"
return "manual"
def build_identify_payload() -> dict[str, Any]:
"""Default ``identify`` answer, built from gateway.status primitives."""
from gateway.status import (
_build_pid_record,
_get_code_identity_fields,
_profile_label_for_home,
read_runtime_status,
)
record = _build_pid_record()
payload: dict[str, Any] = {
"protocol": CONTROL_PROTOCOL_VERSION,
"kind": record.get("kind"),
"pid": record.get("pid"),
"start_time": record.get("start_time"),
"hermes_home": record.get("hermes_home"),
"profile": _profile_label_for_home(record.get("hermes_home") or ""),
"supervisor": _detect_supervisor(),
}
payload.update(_get_code_identity_fields())
# served_profiles (multiplex mode) is stamped into the runtime status by
# the runner; surface it when present so fleet consumers see coverage.
try:
runtime = read_runtime_status() or {}
served = runtime.get("served_profiles")
if isinstance(served, list) and served:
payload["served_profiles"] = served
except Exception:
pass
return payload
def build_status_payload() -> dict[str, Any]:
"""Default ``status`` answer — current runtime status, answered live."""
from gateway.status import read_runtime_status
payload = read_runtime_status() or {}
payload = dict(payload)
payload["protocol"] = CONTROL_PROTOCOL_VERSION
payload["answered_at"] = time.time()
payload["answering_pid"] = os.getpid()
return payload
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
class GatewayControlServer:
"""Gateway-owned control socket server (identify/status, v1).
Lifecycle is owned by the gateway process: ``start()`` after the PID-file
claim (the point where this process becomes the authoritative gateway for
its HERMES_HOME), ``stop()`` on shutdown. All failures are non-fatal —
the gateway never refuses to serve messaging because its control socket
couldn't bind; consumers simply fall back to the scan layer.
"""
def __init__(
self,
home: Optional[Path] = None,
*,
verb_handlers: Optional[dict[str, Callable[[], dict[str, Any]]]] = None,
) -> None:
if home is None:
from gateway.status import _get_process_hermes_home
home = _get_process_hermes_home()
self._home = Path(home)
self._server: Optional[asyncio.AbstractServer] = None
self._pipe_server: Any = None # Windows proactor pipe server
self._bind_path: Optional[Path] = None
self._pointer_file: Optional[Path] = None
self._handlers: dict[str, Callable[[], dict[str, Any]]] = {
"identify": build_identify_payload,
"status": build_status_payload,
}
if verb_handlers:
self._handlers.update(verb_handlers)
# -- lifecycle ---------------------------------------------------------
async def start(self) -> bool:
"""Bind and start serving. Returns True on success, False otherwise."""
try:
if _IS_WINDOWS:
return await self._start_windows()
return await self._start_posix()
except Exception as exc:
logger.warning("Gateway control socket failed to start (non-fatal): %s", exc)
return False
async def _start_posix(self) -> bool:
bind_path, pointer_file = resolve_server_socket_path(self._home)
# Clear a stale socket left by a crashed predecessor. We only get
# here after winning the PID-file O_EXCL race, so any existing file
# is either stale or a plain collision — never a live sibling for
# this HERMES_HOME.
with contextlib.suppress(OSError):
if bind_path.exists():
bind_path.unlink()
# Bind under a restrictive umask so the socket is never
# world-connectable, even for the instant before an explicit chmod
# could run. Restore the process umask immediately after.
old_umask = os.umask(0o177)
try:
self._server = await asyncio.start_unix_server(
self._handle_connection, path=str(bind_path)
)
finally:
os.umask(old_umask)
with contextlib.suppress(OSError):
os.chmod(bind_path, 0o600)
self._bind_path = bind_path
if pointer_file is not None:
pointer_file.write_text(str(bind_path), encoding="utf-8")
self._pointer_file = pointer_file
logger.info("Gateway control socket listening at %s", bind_path)
return True
async def _start_windows(self) -> bool:
loop = asyncio.get_running_loop()
start_serving_pipe = getattr(loop, "start_serving_pipe", None)
if start_serving_pipe is None:
logger.debug(
"Event loop %s has no start_serving_pipe — control socket "
"disabled (selector loop on Windows).",
type(loop).__name__,
)
return False
pipe_name = windows_pipe_name(self._home)
def _factory():
return _PipeControlProtocol(self)
servers = await start_serving_pipe(_factory, pipe_name)
self._pipe_server = servers[0] if servers else None
logger.info("Gateway control pipe listening at %s", pipe_name)
return self._pipe_server is not None
async def stop(self) -> None:
"""Stop serving and remove the socket/pointer files."""
if self._server is not None:
self._server.close()
with contextlib.suppress(Exception):
await self._server.wait_closed()
self._server = None
if self._pipe_server is not None:
with contextlib.suppress(Exception):
self._pipe_server.close()
self._pipe_server = None
self.cleanup_files()
def cleanup_files(self) -> None:
"""Best-effort removal of socket + pointer files (atexit-safe)."""
if self._bind_path is not None:
with contextlib.suppress(OSError):
self._bind_path.unlink(missing_ok=True)
if self._pointer_file is not None:
with contextlib.suppress(OSError):
self._pointer_file.unlink(missing_ok=True)
# -- request handling ----------------------------------------------------
def handle_request_line(self, raw: bytes) -> bytes:
"""Process one JSON request line, return one JSON response line.
Shared by the POSIX stream handler and the Windows pipe protocol.
Never raises.
"""
request_id: Any = None
try:
request = json.loads(raw.decode("utf-8"))
if not isinstance(request, dict):
raise ValueError("request must be a JSON object")
request_id = request.get("id")
verb = request.get("verb")
handler = self._handlers.get(verb) if isinstance(verb, str) else None
if handler is None:
response: dict[str, Any] = {
"ok": False,
"error": f"unknown verb: {verb!r}",
"protocol": CONTROL_PROTOCOL_VERSION,
"supported_verbs": sorted(self._handlers),
}
else:
response = {
"ok": True,
"protocol": CONTROL_PROTOCOL_VERSION,
"result": handler(),
}
except Exception as exc:
response = {
"ok": False,
"error": f"{type(exc).__name__}: {exc}",
"protocol": CONTROL_PROTOCOL_VERSION,
}
if request_id is not None:
response["id"] = request_id
try:
encoded = json.dumps(response, default=str).encode("utf-8")
except Exception:
encoded = b'{"ok": false, "error": "response serialization failed"}'
if len(encoded) > _MAX_RESPONSE_BYTES:
encoded = b'{"ok": false, "error": "response too large"}'
return encoded + b"\n"
async def _handle_connection(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
try:
raw = await asyncio.wait_for(
reader.readline(), timeout=_DEFAULT_CLIENT_TIMEOUT
)
if not raw or len(raw) > _MAX_REQUEST_BYTES:
return
# Handlers read state files from disk; keep that off the
# gateway's event loop (the same loop drives every platform
# adapter), so a fast-polling consumer can't stall heartbeats.
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, self.handle_request_line, raw.rstrip(b"\n")
)
writer.write(response)
await writer.drain()
except (asyncio.TimeoutError, ConnectionError, OSError):
pass
except Exception:
logger.debug("Control socket connection handler error", exc_info=True)
finally:
with contextlib.suppress(Exception):
writer.close()
class _PipeControlProtocol(asyncio.Protocol):
"""One-shot request/response protocol for the Windows named pipe."""
def __init__(self, server: GatewayControlServer) -> None:
self._server = server
self._transport: Any = None
self._buffer = bytearray()
def connection_made(self, transport) -> None: # pragma: no cover - windows
self._transport = transport
def data_received(self, data: bytes) -> None: # pragma: no cover - windows
self._buffer.extend(data)
if len(self._buffer) > _MAX_REQUEST_BYTES:
self._transport.close()
return
if b"\n" in self._buffer:
line, _, _ = bytes(self._buffer).partition(b"\n")
try:
self._transport.write(self._server.handle_request_line(line))
finally:
self._transport.close()
# ---------------------------------------------------------------------------
# Client (synchronous — used by CLI/updater consumers)
# ---------------------------------------------------------------------------
def query_gateway_control(
home: Path,
verb: str,
*,
timeout: float = _DEFAULT_CLIENT_TIMEOUT,
) -> Optional[dict[str, Any]]:
"""Ask the gateway serving ``home`` a control verb; None when unanswered.
Returns the verb's ``result`` payload on success. Any failure — no
socket, stale socket nobody accepts on, timeout, malformed answer,
``ok: false`` — returns None so callers fall back to the scan layer.
Never raises.
"""
request = (
json.dumps({"verb": verb, "id": 1, "protocol": CONTROL_PROTOCOL_VERSION})
.encode("utf-8")
+ b"\n"
)
try:
if _IS_WINDOWS:
raw = _query_windows_pipe(Path(home), request, timeout)
else:
raw = _query_unix_socket(Path(home), request, timeout)
except Exception:
return None
if not raw:
return None
try:
response = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return None
if not isinstance(response, dict) or response.get("ok") is not True:
return None
result = response.get("result")
return result if isinstance(result, dict) else None
def _query_unix_socket(home: Path, request: bytes, timeout: float) -> Optional[bytes]:
path = resolve_client_socket_path(home)
if path is None:
return None
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
try:
sock.connect(str(path))
except (ConnectionRefusedError, FileNotFoundError, OSError):
return None
sock.sendall(request)
chunks: list[bytes] = []
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
chunk = sock.recv(65536)
except socket.timeout:
return None
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
if sum(len(c) for c in chunks) > _MAX_RESPONSE_BYTES:
return None
data = b"".join(chunks)
line, _, _ = data.partition(b"\n")
return line or None
def _query_windows_pipe(
home: Path, request: bytes, timeout: float
) -> Optional[bytes]: # pragma: no cover - exercised on the wine2e lane
pipe_name = windows_pipe_name(home)
deadline = time.monotonic() + timeout
handle = None
while handle is None:
try:
handle = open(pipe_name, "r+b", buffering=0)
except FileNotFoundError:
return None
except OSError:
# Pipe busy (another client mid-handshake) — brief retry window.
if time.monotonic() >= deadline:
return None
time.sleep(0.05)
try:
handle.write(request)
chunks: list[bytes] = []
while time.monotonic() < deadline:
chunk = handle.read(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
if sum(len(c) for c in chunks) > _MAX_RESPONSE_BYTES:
return None
data = b"".join(chunks)
line, _, _ = data.partition(b"\n")
return line or None
finally:
with contextlib.suppress(Exception):
handle.close()
def identify_gateway(home: Path, *, timeout: float = _DEFAULT_CLIENT_TIMEOUT) -> Optional[dict[str, Any]]:
"""Convenience wrapper: ``identify`` the gateway serving ``home``."""
return query_gateway_control(home, "identify", timeout=timeout)
def pause_gateway_for_update(
home: Path, *, timeout: float = _DEFAULT_CLIENT_TIMEOUT
) -> Optional[dict[str, Any]]:
"""Ask the gateway serving ``home`` to drain and exit for an update.
Step 2 of the socket migration (#92091). Returns the gateway's ACK —
``{"pausing": bool, "already_stopping": bool, "pid": int,
"drain_timeout": float}`` — or None when no gateway answers (older
gateway without the verb, no socket, dead socket). None means the
caller falls back to the legacy pause path (signals / tree-kill),
exactly as before this verb existed.
"""
return query_gateway_control(home, "pause-for-update", timeout=timeout)
+49
View File
@@ -0,0 +1,49 @@
"""Resolve gateway ``terminal.cwd`` placeholder values to ``TERMINAL_CWD``.
When ``terminal.cwd`` is unset or a placeholder (``.``, ``auto``, ``cwd``),
the gateway must not blindly map host ``Path.home()`` into container backends.
Docker with workspace mounting still needs an explicit host path signal
(``MESSAGING_CWD`` or an absolute config path) for ``terminal_tool`` to map
``/host/project`` → ``/workspace``.
"""
from __future__ import annotations
CWD_PLACEHOLDERS = frozenset({".", "auto", "cwd"})
def _truthy_env(value: str | None) -> bool:
return (value or "").strip().lower() in {"true", "1", "yes"}
def resolve_placeholder_terminal_cwd(
*,
configured_cwd: str,
terminal_backend: str,
messaging_cwd: str | None,
docker_mount_cwd_to_workspace: bool,
home_fallback: str,
) -> str | None:
"""Return the ``TERMINAL_CWD`` value to set, or ``None`` to leave it unset.
Cases:
- **local** + placeholder → ``MESSAGING_CWD`` or ``home_fallback``
- **docker** + placeholder + mount on + host ``MESSAGING_CWD`` → host path
(for ``terminal_tool`` ``/workspace`` mapping)
- **docker** + placeholder + mount off → ``None`` (sandbox default)
- other non-local backends + placeholder → ``None``
"""
if configured_cwd and configured_cwd not in CWD_PLACEHOLDERS:
return configured_cwd
backend = (terminal_backend or "local").strip().lower()
if backend == "local":
messaging = (messaging_cwd or "").strip()
return messaging or home_fallback
if backend == "docker" and docker_mount_cwd_to_workspace:
messaging = (messaging_cwd or "").strip()
if messaging and messaging not in CWD_PLACEHOLDERS:
return messaging
return None
+143
View File
@@ -0,0 +1,143 @@
"""Persistent registry of delivery targets that are confirmed unreachable.
When a messaging platform reports that a target chat is permanently gone — a
deleted group (``Forbidden: the group chat was deleted``), a bot kicked/blocked,
or a deactivated user — re-sending to it on every cron tick or every fan-out
delivery wastes a send attempt against the platform's flood-control envelope and
spams the logs. This registry lets the delivery layer short-circuit a target it
has already proven dead, while staying self-healing: any successful send to that
target clears the flag, so a user who re-adds the bot (or restores the chat)
recovers automatically with no manual cleanup.
Scope is deliberately narrow. Only *whole-chat* deaths are recorded — the
``forbidden`` and chat-level ``not_found`` (``chat not found``) error kinds.
Thread/topic-level ``not_found`` is NOT recorded here: the adapters already
self-heal that by retrying without ``reply_to`` (see the Telegram adapter's
reply-target-deleted path), and a deleted topic does not mean the parent chat is
dead.
The store is a small JSON file under the active profile's HERMES_HOME so each
profile keeps its own dead set. Reads/writes are best-effort: a corrupt or
unwritable file degrades to an in-memory-only registry rather than raising on
the delivery path.
"""
from __future__ import annotations
import json
import logging
import threading
import time
from pathlib import Path
from typing import Dict, Optional
from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
# Error kinds (from gateway.platforms.base.classify_send_error) that mean the
# *whole chat* is unreachable, not a transient or thread-level problem.
_DEAD_ERROR_KINDS = frozenset({"forbidden", "not_found"})
def _normalize(platform: str, chat_id: str) -> str:
"""Canonical key for a (platform, chat_id) pair."""
return f"{str(platform).strip().lower()}:{str(chat_id).strip()}"
class DeadTargetRegistry:
"""Thread-safe, persistent set of confirmed-dead delivery targets.
Keyed on ``platform:chat_id``. Stores the reason and a timestamp for
observability. Self-healing: :meth:`clear` (called on a successful send)
removes the flag.
"""
def __init__(self, path: Optional[Path] = None) -> None:
self._lock = threading.RLock()
self._dead: Dict[str, Dict[str, object]] = {}
if path is not None:
self._path = path
else:
self._path = get_hermes_home() / "gateway" / "dead_targets.json"
self._load()
# -- persistence -------------------------------------------------------
def _load(self) -> None:
try:
if self._path.exists():
raw = json.loads(self._path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
# Only keep well-shaped entries.
self._dead = {
k: v for k, v in raw.items() if isinstance(v, dict)
}
except (OSError, ValueError) as exc:
logger.debug("dead_targets: could not load %s (%s) — starting empty",
self._path, exc)
self._dead = {}
def _flush_locked(self) -> None:
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(self._dead, indent=2), encoding="utf-8")
tmp.replace(self._path)
except OSError as exc:
# Best-effort: keep the in-memory state, don't break delivery.
logger.debug("dead_targets: could not persist %s (%s)", self._path, exc)
# -- public API --------------------------------------------------------
@staticmethod
def is_dead_error_kind(error_kind: Optional[str]) -> bool:
"""Return True when ``error_kind`` denotes a permanent whole-chat death."""
return bool(error_kind) and error_kind in _DEAD_ERROR_KINDS
def is_dead(self, platform: str, chat_id: Optional[str]) -> bool:
if not chat_id:
return False
with self._lock:
return _normalize(platform, chat_id) in self._dead
def mark_dead(self, platform: str, chat_id: Optional[str],
reason: str = "") -> bool:
"""Record a target as confirmed-dead. Returns True if newly added."""
if not chat_id:
return False
key = _normalize(platform, chat_id)
with self._lock:
existed = key in self._dead
self._dead[key] = {
"platform": str(platform).strip().lower(),
"chat_id": str(chat_id),
"reason": str(reason)[:200],
"marked_at": time.time(),
}
self._flush_locked()
if not existed:
logger.info(
"dead_targets: marked %s as unreachable (%s) — future deliveries "
"to this target will be skipped until a send succeeds",
key, reason or "no reason given",
)
return not existed
def clear(self, platform: str, chat_id: Optional[str]) -> bool:
"""Remove a target's dead flag (self-healing). Returns True if it was set."""
if not chat_id:
return False
key = _normalize(platform, chat_id)
with self._lock:
if key in self._dead:
del self._dead[key]
self._flush_locked()
logger.info("dead_targets: cleared %s (delivery succeeded again)", key)
return True
return False
def all_dead(self) -> Dict[str, Dict[str, object]]:
"""Snapshot of the current dead set (for diagnostics / `hermes` CLI)."""
with self._lock:
return {k: dict(v) for k, v in self._dead.items()}
+657
View File
@@ -0,0 +1,657 @@
"""
Delivery routing for cron job outputs and agent responses.
Routes messages to the appropriate destination based on:
- Explicit targets (e.g., "telegram:123456789")
- Platform home channels (e.g., "telegram" → home channel)
- Origin (back to where the job was created)
- Local (always saved to files)
"""
import logging
import os
import re
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass
from typing import Dict, List, Optional, Any
from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
# Cap before gateway-level truncation of cron output for non-chunking platform
# delivery. Telegram's hard API limit is 4096; the headroom covers the "full
# output saved to …" footer appended on truncation. Adapters that split long
# messages natively (BasePlatformAdapter.splits_long_messages) bypass this
# entirely — the adapter chunks in its own send() and the full output is
# preserved.
MAX_PLATFORM_OUTPUT = 4000
# Matches strings that are *only* a "silence" narration with optional markdown
# wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,
# 🔇, a bare ".", "…", and the whitespace/marker-padded variants seen in the
# wild. Anchored to start/end so substantive messages that merely *contain* the
# word "silent" are never matched.
_SILENCE_NARRATION = re.compile(
r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
r'|^[\s*_~`]*[\U0001F507\.\u2026]+[\s*_~`]*$',
re.IGNORECASE,
)
def _is_silence_narration(content: Optional[str]) -> bool:
"""Return True when ``content`` is *only* a silence-narration token.
Length-guarded (real messages are longer) and anchored to the whole string
so legitimate prose like "The deployment ran silently" or "Silence is
golden — here is the plan..." is never flagged.
"""
if not content:
return False
stripped = content.strip()
if not stripped or len(stripped) > 64: # length guard
return False
return bool(_SILENCE_NARRATION.match(stripped))
from .config import Platform, GatewayConfig, PlatformConfig
from .session import SessionSource
from .dead_targets import DeadTargetRegistry
@dataclass(frozen=True)
class DeliveryTransport:
"""Resolved live transport for one logical delivery platform."""
adapter: Any
config: Optional[PlatformConfig]
transport_platform: Platform
@property
def is_relay(self) -> bool:
return self.transport_platform == Platform.RELAY
async def send(
self,
logical_platform: Platform,
chat_id: str,
content: str,
metadata: Optional[Dict[str, Any]],
) -> Any:
"""Send through this transport while preserving the logical platform."""
if self.is_relay:
return await self.adapter.send_for_platform(
logical_platform,
chat_id,
content,
metadata=metadata,
)
return await self.adapter.send(chat_id, content, metadata=metadata)
def resolve_delivery_transport(
platform: Platform,
config: GatewayConfig,
adapters: Optional[Dict[Platform, Any]],
) -> Optional[DeliveryTransport]:
"""Resolve a logical platform to its live delivery transport.
A concrete native adapter always wins. Relay is eligible only when its
authenticated transport explicitly advertises that it fronts the logical
platform, which keeps restart-time delivery independent of per-chat caches
without letting Relay hijack unrelated platform targets.
"""
live_adapters = adapters or {}
native = live_adapters.get(platform)
native_config = config.platforms.get(platform)
# Preserve DeliveryRouter's historical support for explicitly supplied live
# adapters with no config block, but never let an explicitly disabled native
# adapter shadow an enabled Relay transport.
if native is not None and (native_config is None or native_config.enabled):
return DeliveryTransport(
adapter=native,
config=native_config,
transport_platform=platform,
)
relay = live_adapters.get(Platform.RELAY)
relay_config = config.platforms.get(Platform.RELAY)
fronts_platform = getattr(relay, "fronts_platform", None)
if (
relay is not None
and (relay_config is None or relay_config.enabled)
and callable(fronts_platform)
and fronts_platform(platform)
):
return DeliveryTransport(
adapter=relay,
config=relay_config,
transport_platform=Platform.RELAY,
)
return None
def looks_like_telegram_private_chat_id(chat_id: Optional[str]) -> bool:
"""True when ``chat_id`` is a positive int — Telegram's private-chat shape.
Telegram private chats use positive chat IDs; groups/channels/supergroups
use negative IDs. This is the single source of truth for that heuristic,
reused by the handoff seed path in ``gateway/run.py`` so handoff-created
DM topics key the same way as inbound DM-topic messages.
"""
if chat_id is None:
return False
try:
return int(chat_id) > 0
except (TypeError, ValueError):
return False
def _looks_like_int(value: Optional[str]) -> bool:
if value is None:
return False
try:
int(value)
return True
except (TypeError, ValueError):
return False
def _send_result_failed(result: Any) -> bool:
if isinstance(result, dict):
return result.get("success") is False
return getattr(result, "success", True) is False
def _send_result_error(result: Any) -> Optional[str]:
if isinstance(result, dict):
error = result.get("error")
else:
error = getattr(result, "error", None)
return str(error) if error else None
def _is_thread_not_found_delivery_error(result: Any) -> bool:
error = _send_result_error(result)
return bool(error and "thread not found" in error.lower())
def _send_result_error_kind(result: Any) -> Optional[str]:
"""Return the machine-readable error_kind from a SendResult/dict, if any."""
if isinstance(result, dict):
kind = result.get("error_kind")
else:
kind = getattr(result, "error_kind", None)
return str(kind) if kind else None
def _classify_dead_from_error_text(error_text: Optional[str]) -> Optional[str]:
"""Best-effort dead-target classification from a raised error's text.
``_deliver_to_platform`` raises (it does not return a SendResult) on a hard
failure, so the ``deliver()`` loop only has the exception string. Reuse the
platform-neutral classifier to recover the error_kind from that text.
"""
if not error_text:
return None
try:
from .platforms.base import classify_send_error, is_chat_level_not_found
except Exception: # pragma: no cover - import guard
return None
kind = classify_send_error(None, error_text=error_text)
if not DeadTargetRegistry.is_dead_error_kind(kind):
return None
# ``not_found`` collapses chat-level and thread/topic/message-level failures.
# Only a whole-chat not_found means the target is dead — a deleted forum topic
# or an edited-away message must not mark the entire chat (and all of its future
# deliveries) dead. See gateway.dead_targets' documented scope.
if kind == "not_found" and not is_chat_level_not_found(error_text=error_text):
return None
return kind
@dataclass
class DeliveryTarget:
"""
A single delivery target.
Represents where a message should be sent:
- "origin" → back to source
- "local" → save to local files
- "telegram" → Telegram home channel
- "telegram:123456" → specific Telegram chat
"""
platform: Platform
chat_id: Optional[str] = None # None means use home channel
thread_id: Optional[str] = None
is_origin: bool = False
is_explicit: bool = False # True if chat_id was explicitly specified
@classmethod
def parse(cls, target: str, origin: Optional[SessionSource] = None) -> "DeliveryTarget":
"""
Parse a delivery target string.
Formats:
- "origin" → back to source
- "local" → local files only
- "telegram" → Telegram home channel
- "telegram:123456" → specific Telegram chat
"""
target_stripped = target.strip()
target_lower = target_stripped.lower()
if target_lower == "origin":
if origin:
return cls(
platform=origin.platform,
chat_id=origin.chat_id,
thread_id=origin.thread_id,
is_origin=True,
)
else:
# Fallback to local if no origin
return cls(platform=Platform.LOCAL, is_origin=True)
if target_lower == "local":
return cls(platform=Platform.LOCAL)
# Check for platform:chat_id or platform:chat_id:thread_id format
# Use the original case for chat_id/thread_id to preserve case-sensitive IDs
if ":" in target_stripped:
parts = target_stripped.split(":", 2)
platform_str = parts[0].lower() # Platform names are case-insensitive
chat_id = parts[1] if len(parts) > 1 else None
thread_id = parts[2] if len(parts) > 2 else None
try:
platform = Platform(platform_str)
return cls(platform=platform, chat_id=chat_id, thread_id=thread_id, is_explicit=True)
except ValueError:
# Unknown platform, treat as local
return cls(platform=Platform.LOCAL)
# Just a platform name (use home channel)
try:
platform = Platform(target_lower)
return cls(platform=platform)
except ValueError:
# Unknown platform, treat as local
return cls(platform=Platform.LOCAL)
def to_string(self) -> str:
"""Convert back to string format."""
if self.is_origin:
return "origin"
if self.platform == Platform.LOCAL:
return "local"
if self.chat_id and self.thread_id:
return f"{self.platform.value}:{self.chat_id}:{self.thread_id}"
if self.chat_id:
return f"{self.platform.value}:{self.chat_id}"
return self.platform.value
class DeliveryRouter:
"""
Routes messages to appropriate destinations.
Handles the logic of resolving delivery targets and dispatching
messages to the right platform adapters.
"""
def __init__(self, config: GatewayConfig, adapters: Dict[Platform, Any] = None,
dead_targets: Optional[DeadTargetRegistry] = None):
"""
Initialize the delivery router.
Args:
config: Gateway configuration
adapters: Dict mapping platforms to their adapter instances
dead_targets: Optional shared registry of confirmed-unreachable
targets. When omitted, a profile-local registry is created.
"""
self.config = config
self.adapters = adapters or {}
self.output_dir = get_hermes_home() / "cron" / "output"
self.dead_targets = dead_targets or DeadTargetRegistry()
async def deliver(
self,
content: str,
targets: List[DeliveryTarget],
job_id: Optional[str] = None,
job_name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Deliver content to all specified targets.
Args:
content: The message/output to deliver
targets: List of delivery targets
job_id: Optional job ID (for cron jobs)
job_name: Optional job name
metadata: Additional metadata to include
Returns:
Dict with delivery results per target
"""
results = {}
for target in targets:
# Skip targets we've already proven permanently unreachable
# (deleted group, blocked/kicked bot, deactivated user). Re-sending
# to them on every tick wastes a send against flood control and
# spams logs. Self-healing: a later successful send clears the flag.
# LOCAL/origin-without-chat targets are never dead-tracked.
if (
target.platform != Platform.LOCAL
and target.chat_id
and self.dead_targets.is_dead(target.platform.value, target.chat_id)
):
logger.info(
"Skipping delivery to known-dead target %s:%s "
"(send to it again to clear)",
target.platform.value, target.chat_id,
)
results[target.to_string()] = {
"success": False,
"skipped": "dead_target",
"error": "target previously confirmed unreachable",
}
continue
try:
if target.platform == Platform.LOCAL:
result = self._deliver_local(content, job_id, job_name, metadata)
else:
result = await self._deliver_to_platform(target, content, metadata)
# Successful platform delivery — clear any stale dead flag.
if target.chat_id and not _send_result_failed(result):
self.dead_targets.clear(target.platform.value, target.chat_id)
results[target.to_string()] = {
"success": True,
"result": result
}
except Exception as e:
# A hard failure raises here. If the platform reported a
# whole-chat death, record it so future deliveries short-circuit.
if target.platform != Platform.LOCAL and target.chat_id:
dead_kind = _classify_dead_from_error_text(str(e))
if dead_kind:
self.dead_targets.mark_dead(
target.platform.value, target.chat_id,
reason=f"{dead_kind}: {str(e)[:120]}",
)
results[target.to_string()] = {
"success": False,
"error": str(e)
}
return results
def _deliver_local(
self,
content: str,
job_id: Optional[str],
job_name: Optional[str],
metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
"""Save content to local files."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if job_id:
output_path = self.output_dir / job_id / f"{timestamp}.md"
else:
output_path = self.output_dir / "misc" / f"{timestamp}.md"
output_path.parent.mkdir(parents=True, exist_ok=True)
# Build the output document
lines = []
if job_name:
lines.append(f"# {job_name}")
else:
lines.append("# Delivery Output")
lines.append("")
lines.append(f"**Timestamp:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if job_id:
lines.append(f"**Job ID:** {job_id}")
if metadata:
for key, value in metadata.items():
lines.append(f"**{key}:** {value}")
lines.append("")
lines.append("---")
lines.append("")
lines.append(content)
output_path.write_text("\n".join(lines), encoding="utf-8")
return {
"path": str(output_path),
"timestamp": timestamp
}
def _save_full_output(self, content: str, job_id: str) -> Path:
"""Save full cron output to disk and return the file path."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = get_hermes_home() / "cron" / "output"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{job_id}_{timestamp}.txt"
path.write_text(content, encoding="utf-8")
return path
def _filter_silence_narration_enabled(self) -> bool:
"""Whether the outbound silence-narration filter is active.
``HERMES_FILTER_SILENCE_NARRATION`` env var overrides config when set;
otherwise the ``gateway.filter_silence_narration`` config flag wins
(default True).
"""
env = os.getenv("HERMES_FILTER_SILENCE_NARRATION")
if env is not None:
return env.strip().lower() in ("1", "true", "yes", "on")
return bool(getattr(self.config, "filter_silence_narration", True))
async def _deliver_to_platform(
self,
target: DeliveryTarget,
content: str,
metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
"""Deliver content to a messaging platform."""
transport = resolve_delivery_transport(target.platform, self.config, self.adapters)
if transport is None:
raise ValueError(f"No adapter configured for {target.platform.value}")
adapter = transport.adapter
if not target.chat_id:
raise ValueError(f"No chat ID for {target.platform.value} delivery")
# Guard: handle oversized cron output.
#
# Two independent decisions:
# 1. AUDIT SAVE — when content exceeds MAX_PLATFORM_OUTPUT, the full
# output is always written to disk as a recoverable audit trail.
# This fires regardless of adapter capability (best-effort).
# 2. TRUNCATION — for non-chunking adapters, content above the cap is
# truncated with a footer pointing to the saved file. Chunking-
# capable adapters (splits_long_messages=True) receive the full
# payload and split natively in their send().
job_id = (metadata or {}).get("job_id", "unknown")
saved_path: Optional[Path] = None
if len(content) > MAX_PLATFORM_OUTPUT:
# Step 1 — audit save (best-effort). The save is a side-effect
# audit trail, not essential to delivery. If it fails (full disk,
# permissions), delivery proceeds — the content reaches the adapter
# regardless.
try:
saved_path = self._save_full_output(content, job_id)
except OSError as exc:
logger.warning(
"Audit save failed for cron output (%d chars, job=%s): %s — "
"delivery proceeds without audit copy",
len(content), job_id, exc,
)
# Step 2 — truncation (only for non-chunking adapters).
if getattr(adapter, "splits_long_messages", False):
# Adapter chunks natively — deliver full payload.
if saved_path:
logger.info(
"Cron output preserved for chunking adapter (%d chars) — "
"full output saved to %s",
len(content), saved_path,
)
else:
# Non-chunking adapter — truncate with footer. The footer
# needs a valid path, so if the best-effort save above failed,
# retry it here (a failure now is a real delivery problem).
if saved_path is None:
saved_path = self._save_full_output(content, job_id)
footer = f"\n\n... [truncated, full output saved to {saved_path}]"
visible = max(0, MAX_PLATFORM_OUTPUT - len(footer))
logger.info(
"Cron output truncated (%d chars) — full output: %s",
len(content), saved_path,
)
content = content[:visible] + footer
# Substrate-level anti-loop guard: drop hallucinated "silence narration"
# (*(silent)*, 🔇, a bare ".", etc.) before it ever reaches the adapter.
# In bot-to-bot channels these tokens mirror back and forth until a
# model crashes with "no content after all retries". Behavioral prompt
# rules drift across providers; this single chokepoint covers every
# platform adapter regardless of which persona's prompt failed.
# Local/file delivery (_deliver_local) is a separate path and is never
# filtered — saved silence has no loop risk.
# Cron output is an ARTIFACT, not model chatter: a job whose brief is
# legitimately terse ("...", a single 🔇 from a script) has no bot-to-bot
# mirror loop to guard against, and dropping it here while returning
# {"success": True} is exactly how a cron was logged as delivered with
# nothing on the wire (#77763). Cron sends carry job_id in metadata;
# every other caller keeps the filter unchanged.
is_cron_artifact = "job_id" in (metadata or {})
if (
self._filter_silence_narration_enabled()
and not is_cron_artifact
and _is_silence_narration(content)
):
logger.warning(
"Dropped silence-narration outbound to %s (chat=%s): %r",
target.platform.value,
target.chat_id,
content[:40],
)
return {
"success": True,
"filtered": "silence_narration",
"delivered": False,
}
send_metadata = dict(metadata or {})
if transport.is_relay:
home = self.config.get_home_channel(target.platform)
if home is not None and home.chat_id == target.chat_id:
if home.user_id:
send_metadata["user_id"] = home.user_id
if home.scope_id:
send_metadata["scope_id"] = home.scope_id
is_named_telegram_private_topic = False
named_telegram_private_topic_name: Optional[str] = None
if target.thread_id:
has_explicit_direct_topic = (
"direct_messages_topic_id" in send_metadata
or "telegram_direct_messages_topic_id" in send_metadata
)
target_thread_id = target.thread_id
is_named_telegram_private_topic = (
target.platform == Platform.TELEGRAM
and looks_like_telegram_private_chat_id(target.chat_id)
and not _looks_like_int(target_thread_id)
and "thread_id" not in send_metadata
and "message_thread_id" not in send_metadata
and not has_explicit_direct_topic
)
if is_named_telegram_private_topic:
named_telegram_private_topic_name = target_thread_id
ensure_dm_topic = getattr(adapter, "ensure_dm_topic", None)
if ensure_dm_topic is None:
raise RuntimeError(
"Telegram adapter cannot create named private DM topics"
)
created_thread_id = await ensure_dm_topic(target.chat_id, target_thread_id)
if not created_thread_id:
raise RuntimeError(
f"Failed to create Telegram private DM topic '{target_thread_id}'"
)
target_thread_id = str(created_thread_id)
send_metadata["thread_id"] = target_thread_id
send_metadata["telegram_dm_topic_created_for_send"] = True
elif (
target.platform == Platform.TELEGRAM
and looks_like_telegram_private_chat_id(target.chat_id)
and "thread_id" not in send_metadata
and "message_thread_id" not in send_metadata
and not has_explicit_direct_topic
):
# Legacy private topic/thread ids that were not created by this
# send path may still need a reply anchor to stay visible in the
# requested lane. Named targets are created above via
# createForumTopic and can use message_thread_id directly.
reply_anchor = send_metadata.get("telegram_reply_to_message_id")
if reply_anchor is None:
raise RuntimeError(
"Telegram private DM topic delivery requires telegram_reply_to_message_id; "
"send to the bare chat or provide a reply anchor"
)
send_metadata["thread_id"] = target_thread_id
send_metadata["telegram_dm_topic_reply_fallback"] = True
elif "thread_id" not in send_metadata and "message_thread_id" not in send_metadata and not has_explicit_direct_topic:
send_metadata["thread_id"] = target_thread_id
result = await transport.send(
target.platform,
target.chat_id,
content,
metadata=send_metadata or None,
)
if _send_result_failed(result):
if (
is_named_telegram_private_topic
and named_telegram_private_topic_name
and _is_thread_not_found_delivery_error(result)
):
ensure_dm_topic = getattr(adapter, "ensure_dm_topic", None)
if ensure_dm_topic is None:
raise RuntimeError(
"Telegram adapter cannot refresh named private DM topics"
)
refreshed_thread_id = await ensure_dm_topic(
target.chat_id,
named_telegram_private_topic_name,
force_create=True,
)
if not refreshed_thread_id:
raise RuntimeError(
f"Failed to refresh Telegram private DM topic '{named_telegram_private_topic_name}'"
)
send_metadata["thread_id"] = str(refreshed_thread_id)
send_metadata["telegram_dm_topic_created_for_send"] = True
result = await transport.send(
target.platform,
target.chat_id,
content,
metadata=send_metadata or None,
)
if _send_result_failed(result):
raise RuntimeError(_send_result_error(result) or f"{target.platform.value} delivery failed")
return result
+562
View File
@@ -0,0 +1,562 @@
"""Durable delivery-obligation ledger for gateway final responses.
A final agent response that was generated but not yet confirmed-delivered
to the messaging platform is the one artifact the gateway can lose without
a trace: the turn already burned its tokens, the text exists only in a
Python local, and a crash / planned restart between finalize and platform
ACK drops it silently (#58818, #41696, #63695).
This module records a small durable row per outbound final response in the
shared ``state.db`` (same file and conventions as
``tools.async_delegation`` — WAL, owner pid + process-start-time liveness,
bounded retention). The gateway writes three checkpoints around the send:
record_obligation() state='pending' before any send attempt
mark_attempting() state='attempting' immediately before the await
mark_delivered() / state='delivered' only on SendResult.success
mark_failed() state='failed' on a definitive rejection
On startup, ``sweep_recoverable()`` claims rows whose owning process is
dead and hands them to the gateway for redelivery. After a platform adapter
reconnects without a process restart, ``sweep_failed_for_runtime()`` may claim
only the same live process's explicitly allowlisted transient failures. Crash
semantics are explicit about ambiguity (the contract review of the earlier
delivery-outbox attempt, #61790, closed it for silently resending ambiguous
sends):
- ``pending`` — the send never started: redeliver plainly, no dup risk.
- ``attempting`` — crashed mid-await: the platform MAY already have the
message. Redelivered WITH a visible recovered-reply marker so the
contract is honest at-least-once, never a silent duplicate.
- ``failed`` — definitively rejected once; the restart is a natural
retry boundary. Also carries the marker.
- ``delivered`` — nothing to do; retention prunes.
Poison rows cannot spin: attempts are capped, stale rows expire, and both
transition to ``abandoned`` (kept briefly for inspection, then pruned).
Everything here is best-effort by design: ledger failures must never block
or delay an actual send. Callers wrap every call in try/except.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import sqlite3
import threading
import time
from contextlib import contextmanager
from typing import Any, Dict, Iterator, List, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
_DB_LOCK = threading.Lock()
# Redelivery policy knobs (module constants; deliberately not config — the
# ledger itself is gated by ``gateway.delivery_ledger`` and these bounds
# only matter in the rare recovery path).
MAX_ATTEMPTS = 3
STALE_AFTER_SECONDS = 24 * 60 * 60
_RETENTION_SECONDS = 7 * 24 * 60 * 60
_MAX_ROWS = 500
# Visible prefix for redeliveries that might duplicate an already-received
# message (crash mid-send / post-rejection retry). Honest at-least-once.
RECOVERED_MARKER = (
"♻️ Recovered reply — the gateway restarted during delivery, "
"so this may be a duplicate:\n\n"
)
# Runtime recovery uses a distinct marker because no gateway restart occurred.
# Keep the ambiguity explicit: a network rejection normally means the platform
# did not accept the message, but an acknowledgement can be lost independently.
RECONNECTED_MARKER = (
"♻️ Recovered reply — the messaging platform reconnected after the original "
"delivery failed, so this may be a duplicate:\n\n"
)
# Runtime replay is deliberately fail-closed. Only errors whose send contract
# proves they are transient reconnect failures belong here; permanent rejects
# (blocked bot, bad auth, missing chat) must not be retried merely because an
# adapter reconnected.
_RUNTIME_RETRYABLE_ERRORS = frozenset({"send_path_degraded"})
def _db_path():
return get_hermes_home() / "state.db"
def _connect() -> sqlite3.Connection:
path = _db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=10)
try:
_initialize_schema(conn)
except Exception:
# A PRAGMA/DDL failure after a successful connect() must not leak the
# just-opened connection back to the caller.
conn.close()
raise
return conn
def _initialize_schema(conn: sqlite3.Connection) -> None:
from hermes_state import apply_wal_with_fallback
apply_wal_with_fallback(conn, db_label="state.db (delivery_ledger)")
conn.execute(
"""CREATE TABLE IF NOT EXISTS delivery_obligations (
obligation_id TEXT PRIMARY KEY,
session_key TEXT NOT NULL,
platform TEXT NOT NULL,
chat_id TEXT NOT NULL,
thread_id TEXT,
content TEXT NOT NULL,
state TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
owner_pid INTEGER,
owner_started_at INTEGER,
last_error TEXT,
adapter_profile TEXT
)"""
)
columns = {
row[1] for row in conn.execute("PRAGMA table_info(delivery_obligations)")
}
if "adapter_profile" not in columns:
try:
conn.execute(
"ALTER TABLE delivery_obligations ADD COLUMN adapter_profile TEXT"
)
except sqlite3.OperationalError as exc:
# Concurrent first-use connections can both observe the old schema.
if "duplicate column" not in str(exc).lower():
raise
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
"""Open a connection, commit/rollback on exit, and ALWAYS close it.
``sqlite3.Connection.__enter__``/``__exit__`` only commit or roll back the
transaction; they do not close the connection. Using ``with _connect()``
alone therefore leaks a connection — and its WAL/SHM file descriptors — on
every call, deferring the close to the garbage collector. On a long-running
gateway that exhausts ``RLIMIT_NOFILE`` (the cron-ledger sibling of this
bug was #69567 / PR #69594). ``record_obligation`` runs on every outbound
final response, so this ledger is the highest-frequency leaker.
"""
conn = _connect()
try:
with conn:
yield conn
finally:
conn.close()
def _owner_stamp() -> tuple[int, Optional[int]]:
pid = os.getpid()
try:
from gateway.status import get_process_start_time
return pid, get_process_start_time(pid)
except Exception:
return pid, None
def _owner_alive(pid: Any, started_at: Any) -> bool:
"""True when the recorded owning process still exists (pid + start time)."""
if not pid:
return False
try:
pid = int(pid)
except (TypeError, ValueError):
return False
try:
from gateway.status import get_process_start_time
current_start = get_process_start_time(pid)
except Exception:
current_start = None
if current_start is None:
# No such process (or unreadable) — treat unreadable-but-extant
# processes as alive only if the pid exists. Route through the
# cross-platform probe: ``os.kill(pid, 0)`` on Windows is NOT a
# no-op (bpo-14484 — CPython maps sig=0 to
# ``GenerateConsoleCtrlEvent(0, pid)``), so a raw probe here could
# Ctrl+C the gateway's own console group whenever psutil failed to
# read the start time of a live pid. ``_pid_exists`` keeps the
# EPERM-means-alive semantics (exists but owned by another user).
try:
from gateway.status import _pid_exists
except Exception:
if os.name == "nt":
# Never fall back to a raw sig-0 probe on Windows.
return False
try:
os.kill(pid, 0) # windows-footgun: ok — POSIX-only fallback branch
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
try:
return bool(_pid_exists(pid))
except Exception:
return False
if started_at is None:
return True
try:
return int(current_start) == int(started_at)
except (TypeError, ValueError):
return True
def compute_obligation_id(session_key: str, message_ref: str, content: str) -> str:
"""Stable id: same turn + same content re-records idempotently, while
distinct threads/topics on the same chat can never collide (the
session_key carries platform, chat and thread; ``message_ref`` is the
triggering inbound message id, distinguishing turns in one session)."""
payload = f"{session_key}|{message_ref}|{content}"
return hashlib.sha256(payload.encode("utf-8", "replace")).hexdigest()[:24]
def record_obligation(
*,
obligation_id: str,
session_key: str,
platform: str,
chat_id: str,
thread_id: Optional[str],
content: str,
adapter_profile: Optional[str] = None,
) -> None:
"""Record a final response as owed to the platform (state='pending')."""
now = time.time()
stored_profile = str(adapter_profile).strip() if adapter_profile else "default"
pid, started = _owner_stamp()
with _DB_LOCK, _transaction() as conn:
conn.execute(
"""INSERT OR REPLACE INTO delivery_obligations
(obligation_id, session_key, platform, chat_id, thread_id,
content, state, attempts, created_at, updated_at,
owner_pid, owner_started_at, adapter_profile)
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?, ?)""",
(obligation_id, session_key, platform, str(chat_id),
str(thread_id) if thread_id else None, content, now, now,
pid, started, stored_profile),
)
_prune()
def mark_attempting(obligation_id: str) -> None:
_update_state(obligation_id, "attempting")
def mark_delivered(obligation_id: str) -> None:
_update_state(obligation_id, "delivered")
def mark_failed(obligation_id: str, error: str = "") -> None:
_update_state(obligation_id, "failed", error=error)
def release_runtime_claim(obligation_id: str, error: str = "") -> bool:
"""Return an unsent runtime claim to ``failed`` without spending an attempt.
Runtime recovery claims before clearing ``resume_pending`` so that two
reconnect paths cannot send the same row. If the session flag cannot be
cleared, no platform send was attempted and the claim must not consume the
bounded redelivery budget. Release is fail-closed to the exact current
process instance and the ``attempting`` state.
"""
pid, started = _owner_stamp()
if started is None:
return False
with _DB_LOCK, _transaction() as conn:
cursor = conn.execute(
"""UPDATE delivery_obligations
SET state='failed', attempts=CASE
WHEN attempts > 0 THEN attempts - 1 ELSE 0 END,
updated_at=?, last_error=?
WHERE obligation_id=? AND state='attempting'
AND owner_pid IS ? AND owner_started_at IS ?""",
(time.time(), error[:500] if error else None,
obligation_id, pid, started),
)
return bool(cursor.rowcount)
def _update_state(obligation_id: str, state: str, error: str = "") -> None:
with _DB_LOCK, _transaction() as conn:
conn.execute(
"""UPDATE delivery_obligations
SET state=?, updated_at=?, last_error=?
WHERE obligation_id=?""",
(state, time.time(), error[:500] if error else None, obligation_id),
)
def sweep_recoverable(
now: Optional[float] = None,
*,
deliverable_platforms: Optional[set] = None,
deliverable_targets: Optional[set] = None,
) -> List[Dict[str, Any]]:
"""Claim undelivered rows owned by dead processes; return them for
redelivery.
Claiming atomically re-stamps the owner to THIS process and increments
``attempts``, so a second gateway racing the same sweep cannot
double-claim (the UPDATE is guarded on the previous owner stamp).
Rows over the attempts cap or older than the stale cutoff transition to
'abandoned' instead of being returned.
``deliverable_platforms`` (platform value strings) restricts claiming to
platforms the caller can actually send on this boot. ``attempts`` is the
redelivery budget, so it must only be spent on a real send: a platform
that failed to connect would otherwise burn one attempt per boot and hit
the cap having never been sent once. Rows for absent platforms are left
untouched for a later boot; the stale cutoff still bounds them.
``deliverable_targets`` further scopes multiplexed gateways by exact
``(platform, adapter_profile)`` identity, preventing one connected bot from
spending another disconnected bot's retry budget.
"""
now = now if now is not None else time.time()
pid, started = _owner_stamp()
claimed: List[Dict[str, Any]] = []
with _DB_LOCK, _transaction() as conn:
rows = conn.execute(
"""SELECT obligation_id, session_key, platform, chat_id, thread_id,
content, state, attempts, created_at,
owner_pid, owner_started_at, adapter_profile
FROM delivery_obligations
WHERE state IN ('pending', 'attempting', 'failed')"""
).fetchall()
for (oid, session_key, platform, chat_id, thread_id, content, state,
attempts, created_at, owner_pid, owner_started_at,
adapter_profile) in rows:
if _owner_alive(owner_pid, owner_started_at):
continue # a live gateway still owns this row
if attempts >= MAX_ATTEMPTS or (now - created_at) > STALE_AFTER_SECONDS:
conn.execute(
"""UPDATE delivery_obligations
SET state='abandoned', updated_at=? WHERE obligation_id=?""",
(now, oid),
)
continue
if (
deliverable_platforms is not None
and platform not in deliverable_platforms
):
# No adapter for this platform this boot — the caller cannot
# send, so claiming would spend an attempt on a no-op.
continue
if (
deliverable_targets is not None
and (platform, adapter_profile) not in deliverable_targets
):
continue
cursor = conn.execute(
"""UPDATE delivery_obligations
SET owner_pid=?, owner_started_at=?, attempts=attempts+1,
updated_at=?
WHERE obligation_id=? AND (owner_pid IS ? OR owner_pid=?)""",
(pid, started, now, oid, owner_pid, owner_pid),
)
if cursor.rowcount:
claimed.append({
"obligation_id": oid,
"session_key": session_key,
"platform": platform,
"chat_id": chat_id,
"thread_id": thread_id,
"content": content,
# pending = send never started, redeliver plainly;
# attempting/failed = ambiguous or rejected, carry marker.
"needs_marker": state != "pending",
"profile": adapter_profile,
"attempts": attempts + 1,
})
return claimed
def sweep_failed_for_runtime(
platform: str,
now: Optional[float] = None,
*,
profile: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Claim this process's reconnect-retryable failed rows for one adapter.
``profile`` scopes multiplexed gateways to the bot identity that actually
owned the failed send; ``None`` means the primary/default adapter. The
persisted adapter owner is independent of the routed session namespace.
Startup recovery intentionally ignores rows owned by a live gateway. That
protects concurrent processes, but it also means a final response rejected
with ``send_path_degraded`` remains stranded when only the platform adapter
reconnects. This runtime sweep closes that gap without weakening ownership:
- only rows stamped to this exact process instance are eligible;
- only explicitly allowlisted transient errors are eligible;
- attempts/staleness bounds match startup recovery;
- every update is guarded by the prior owner stamp and ``failed`` state.
Unowned rows and rows owned by another process are left untouched for the
normal startup/dead-owner sweep. Claimed rows always carry the reconnect
marker because the failed send's acknowledgement is not safe to infer.
"""
now = now if now is not None else time.time()
pid, started = _owner_stamp()
if started is None:
# PID equality alone cannot distinguish this process from a stale row
# left by an earlier process incarnation after PID reuse. Runtime replay
# is optional recovery, so fail closed when the process fingerprint is
# unavailable; startup recovery remains the durable fallback.
return []
claimed: List[Dict[str, Any]] = []
with _DB_LOCK, _transaction() as conn:
rows = conn.execute(
"""SELECT obligation_id, session_key, platform, chat_id, thread_id,
content, attempts, created_at, owner_pid,
owner_started_at, last_error, adapter_profile
FROM delivery_obligations
WHERE state='failed' AND platform=?""",
(platform,),
).fetchall()
for (
oid,
session_key,
row_platform,
chat_id,
thread_id,
content,
attempts,
created_at,
owner_pid,
owner_started_at,
last_error,
adapter_profile,
) in rows:
expected_profile = (
"default" if not profile or profile == "default" else str(profile)
)
if adapter_profile != expected_profile:
continue
# Runtime reconnect recovery may act only on its own rows. Exact
# process-start matching prevents PID reuse from stealing work.
if owner_pid != pid or owner_started_at != started:
continue
if str(last_error or "").strip().lower() not in _RUNTIME_RETRYABLE_ERRORS:
continue
owner_guard = (oid, owner_pid, owner_started_at)
if attempts >= MAX_ATTEMPTS or (now - created_at) > STALE_AFTER_SECONDS:
conn.execute(
"""UPDATE delivery_obligations
SET state='abandoned', updated_at=?
WHERE obligation_id=? AND state='failed'
AND owner_pid IS ? AND owner_started_at IS ?""",
(now, *owner_guard),
)
continue
cursor = conn.execute(
"""UPDATE delivery_obligations
SET state='attempting', attempts=attempts+1, updated_at=?
WHERE obligation_id=? AND state='failed'
AND owner_pid IS ? AND owner_started_at IS ?""",
(now, *owner_guard),
)
if cursor.rowcount:
claimed.append({
"obligation_id": oid,
"session_key": session_key,
"platform": row_platform,
"chat_id": chat_id,
"thread_id": thread_id,
"content": content,
"needs_marker": True,
"marker": RECONNECTED_MARKER,
"profile": adapter_profile,
"runtime_recovery": True,
"attempts": attempts + 1,
})
return claimed
def _prune(now: Optional[float] = None) -> None:
now = now if now is not None else time.time()
cutoff = now - _RETENTION_SECONDS
try:
with _transaction() as conn:
conn.execute(
"""DELETE FROM delivery_obligations
WHERE state IN ('delivered', 'abandoned') AND updated_at < ?""",
(cutoff,),
)
total = conn.execute(
"SELECT COUNT(*) FROM delivery_obligations"
).fetchone()[0]
excess = max(0, total - _MAX_ROWS)
if excess:
conn.execute(
"""DELETE FROM delivery_obligations WHERE obligation_id IN (
SELECT obligation_id FROM delivery_obligations
ORDER BY CASE state
WHEN 'delivered' THEN 0
WHEN 'abandoned' THEN 1
ELSE 2
END, updated_at ASC
LIMIT ?)""",
(excess,),
)
except Exception:
logger.debug("delivery ledger prune failed", exc_info=True)
def ledger_enabled(config: Optional[Dict[str, Any]] = None) -> bool:
"""Read the ``gateway.delivery_ledger`` config gate (default on)."""
try:
if config is None:
from hermes_cli.config import load_config
config = load_config()
gw = config.get("gateway") or {}
value = gw.get("delivery_ledger", True)
if isinstance(value, str):
return value.strip().lower() not in {"false", "0", "no", "off"}
return bool(value)
except Exception:
return True
def debug_rows(limit: int = 20) -> str:
"""Human-readable dump for ad-hoc inspection (sqlite3-free path)."""
with _DB_LOCK, _transaction() as conn:
rows = conn.execute(
"""SELECT obligation_id, session_key, state, attempts,
created_at, updated_at, last_error
FROM delivery_obligations
ORDER BY updated_at DESC LIMIT ?""",
(limit,),
).fetchall()
return json.dumps(
[
{
"id": r[0], "session": r[1], "state": r[2], "attempts": r[3],
"created_at": r[4], "updated_at": r[5], "last_error": r[6],
}
for r in rows
],
indent=2,
)
+117
View File
@@ -0,0 +1,117 @@
"""Disk-usage rollup for ``/api/status`` (NS-656).
Companion to :mod:`gateway.memory_status`, closing the same class of gap
for storage: a hosted agent can fill its data volume completely — SQLite
writes failing, session persistence dead, config saves lost — while its
dashboard and the NAS agent card both look perfectly healthy. Fleet
incidents OOF-2 (unrecoverable disk-full) and OOF-107 (fleet-wide disk
exhaustion, remediated by hand) are exactly this failure mode.
The readiness endpoint already probes disk (``gateway/readiness.py::
_probe_disk``), but readiness is a component verdict, not user-facing
telemetry — nothing renders it. This module produces the public block
the dashboard SPA and the NAS availability sweep actually consume.
Unlike the memory block (which distills already-persisted heartbeat
files), disk is sampled live via :func:`shutil.disk_usage` — a single
``statvfs`` call, the same thing the readiness probe does per request.
There is no meaningful "staleness" dimension, so no ``sampled_at``.
Public-safety note: ``/api/status`` is an unauthenticated liveness probe
(``PUBLIC_API_PATHS``). This block carries only coarse numbers (MB
granularity, whole-percent usage) and an enum — the same disclosure
class as the ``memory`` block.
Everything is best-effort and read-only: an unreadable filesystem
degrades to ``pressure="unknown"`` rather than raising into the status
endpoint.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Disk-pressure thresholds. Percent alone misleads in both directions:
# 90% used on a 100 GB volume leaves a comfortable 10 GB, while 50% used
# on a tiny volume can be one image download from write failures. So the
# percent triggers are gated on absolute headroom also being low, and a
# hard absolute floor applies regardless of size — below it, SQLite
# journaling and config writes are at genuine risk on any volume.
_CRITICAL_FREE_MB = 256 # < 256 MB free: critical on any volume
_CRITICAL_PERCENT = 95.0 # >= 95% used AND < 1 GB free: critical
_CRITICAL_HEADROOM_MB = 1024
_ELEVATED_FREE_MB = 512 # < 512 MB free: elevated on any volume
_ELEVATED_PERCENT = 85.0 # >= 85% used AND < 4 GB free: elevated
_ELEVATED_HEADROOM_MB = 4096
_BYTES_PER_MB = 1024 * 1024
def _coerce_mb(value: Any) -> Optional[int]:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return None
return value
def classify_disk_pressure(free_mb: Any, total_mb: Any) -> str:
"""Map free/total MB to ``ok``/``elevated``/``critical``.
``unknown`` when the sample is missing or malformed — the caller must
not treat "we could not read it" as "disk is fine".
"""
free = _coerce_mb(free_mb)
total = _coerce_mb(total_mb)
if free is None or total is None or total <= 0:
return "unknown"
used_percent = (1 - free / total) * 100.0
if free < _CRITICAL_FREE_MB or (
used_percent >= _CRITICAL_PERCENT and free < _CRITICAL_HEADROOM_MB
):
return "critical"
if free < _ELEVATED_FREE_MB or (
used_percent >= _ELEVATED_PERCENT and free < _ELEVATED_HEADROOM_MB
):
return "elevated"
return "ok"
def collect_disk_status(home: Optional[Path] = None) -> Dict[str, Any]:
"""Build the ``disk`` block for ``/api/status``.
``home`` scopes the sample to a profile's HERMES_HOME (the status
endpoint's ``?profile=`` handling passes it through); on hosted
images every profile shares the ``/opt/data`` volume, so the answer
is the same — but scoping keeps the contract identical to the
``memory`` block's.
Always returns a dict — an unreadable/unmounted filesystem yields
``{"pressure": "unknown", ...}``. Never raises.
"""
status: Dict[str, Any] = {
"pressure": "unknown",
"total_mb": None,
"free_mb": None,
"used_percent": None,
}
try:
if home is None:
from hermes_constants import get_hermes_home
home = get_hermes_home()
usage = shutil.disk_usage(home)
except Exception:
return status
if usage.total <= 0:
return status
total_mb = usage.total // _BYTES_PER_MB
free_mb = usage.free // _BYTES_PER_MB
status["total_mb"] = total_mb
status["free_mb"] = free_mb
status["used_percent"] = round((usage.used / usage.total) * 100, 1)
status["pressure"] = classify_disk_pressure(free_mb, total_mb)
return status
+322
View File
@@ -0,0 +1,322 @@
"""Per-platform display/verbosity configuration resolver.
Provides ``resolve_display_setting()`` — the single entry-point for reading
display settings with platform-specific overrides and sensible defaults.
Resolution order (first non-None wins):
1. ``display.platforms.<platform>.<key>`` — explicit per-platform user override
2. ``display.<key>`` — global user setting
3. ``_PLATFORM_DEFAULTS[<platform>][<key>]`` — built-in sensible default
4. ``_GLOBAL_DEFAULTS[<key>]`` — built-in global default
Exception: ``display.streaming`` is CLI-only. Gateway streaming follows the
top-level ``streaming`` config unless ``display.platforms.<platform>.streaming``
sets an explicit per-platform override.
Backward compatibility: ``display.tool_progress_overrides`` is still read as a
fallback for ``tool_progress`` when no ``display.platforms`` entry exists. A
config migration (version bump) automatically moves the old format into the new
``display.platforms`` structure.
"""
from __future__ import annotations
from typing import Any
# ---------------------------------------------------------------------------
# Overrideable display settings and their global defaults
# ---------------------------------------------------------------------------
# These are the settings that can be configured per-platform.
# Other display settings (compact, personality, skin, etc.) are CLI-only
# and don't participate in per-platform resolution.
_GLOBAL_DEFAULTS: dict[str, Any] = {
"tool_progress": "all",
"tool_progress_grouping": "accumulate", # "accumulate" = edit one bubble; "separate" = one msg per tool
"show_reasoning": False,
# How a reasoning/thinking summary is rendered when show_reasoning is on.
# "code" -> 💭 **Reasoning:** + fenced code block (legacy default)
# "blockquote"-> each line prefixed with "> "
# "subtext" -> each line prefixed with "-# " (Discord small grey subtext)
# Discord defaults to "subtext"; everywhere else defaults to "code".
"reasoning_style": "code",
"tool_preview_length": 0,
"streaming": None, # None = follow top-level streaming config
# Gateway-only assistant/status chatter controls. These default on for
# back-compat, but mobile platforms can opt down to final-answer-first.
"interim_assistant_messages": True,
"long_running_notifications": True,
"busy_ack_detail": True,
# Whether busy_input_mode=steer sends a visible "Steered into current run"
# acknowledgment after successfully injecting the user's mid-turn message.
# Disable when the platform should steer silently (the text still lands in
# the active run; only the confirmation echo is suppressed).
"busy_steer_ack_enabled": True,
# When true, delete tool-progress / "⏳ Working — N min" / status bubbles
# after the final response lands on platforms that support message
# deletion (e.g. Telegram). Off by default — progress is still shown
# live, just cleaned up after success so the chat doesn't fill up with
# stale breadcrumbs. Failed runs leave bubbles in place as breadcrumbs.
"cleanup_progress": False,
# Live working-state status on platforms whose typing indicator renders
# text (Slack's assistant status line). Values:
# "full" / true -> verb + argument preview ("is running pytest…")
# "verb" -> verb only ("is running…") — keeps file paths and
# commands out of shared channels
# "off" / false -> static text (typing_status_text or "is thinking...")
# Independent of tool_progress: works even when progress bubbles are off
# (Slack's default), and costs no extra API calls — the existing typing
# refresh cadence just renders different text.
"live_status": "full",
}
# ---------------------------------------------------------------------------
# Sensible per-platform defaults — tiered by platform capability
# ---------------------------------------------------------------------------
# Tier 1 (high): Supports message editing, typically personal/team use
# Tier 2 (medium): Supports editing but often workspace/customer-facing
# Tier 3 (low): No edit support — each progress msg is permanent
# Tier 4 (minimal): Batch/non-interactive delivery
_TIER_HIGH = {
"tool_progress": "all",
"show_reasoning": False,
"tool_preview_length": 40,
"streaming": None, # follow global
"interim_assistant_messages": True,
"long_running_notifications": True,
"busy_ack_detail": True,
}
_TIER_MEDIUM = {
"tool_progress": "new",
"show_reasoning": False,
"tool_preview_length": 40,
"streaming": None,
"interim_assistant_messages": True,
"long_running_notifications": True,
"busy_ack_detail": True,
}
_TIER_LOW = {
"tool_progress": "off",
"show_reasoning": False,
"tool_preview_length": 40,
"streaming": False,
"interim_assistant_messages": False,
"long_running_notifications": False,
"busy_ack_detail": False,
}
_TIER_MINIMAL = {
"tool_progress": "off",
"show_reasoning": False,
"tool_preview_length": 0,
"streaming": False,
"interim_assistant_messages": False,
"long_running_notifications": False,
"busy_ack_detail": False,
}
_PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
# Tier 1 — full edit support, personal/team use
# Telegram is usually a mobile inbox: keep tool_progress quiet and skip
# the verbose busy-ack iteration counter, but DO surface real mid-turn
# assistant commentary (interim_assistant_messages) and DO send periodic
# heartbeats (long_running_notifications) so the user has signal between
# turn start and final answer. Otherwise it looks like "typing..." for
# 30 minutes with nothing happening. Opt in to verbose iteration detail
# via display.platforms.telegram.busy_ack_detail / tool_progress.
"telegram": {
**_TIER_HIGH,
"tool_progress": "off",
"busy_ack_detail": False,
},
# Discord has a native "subtext" primitive (-# small grey text) that reads
# as metadata rather than content, so reasoning summaries default to it
# here instead of the fenced code block used elsewhere.
"discord": {**_TIER_HIGH, "reasoning_style": "subtext"},
# Tier 2 — edit support, often customer/workspace channels
# Slack: tool_progress off by default — Bolt posts cannot be edited like CLI;
# "new"/"all" spam permanent lines in channels (hermes-agent#14663).
"slack": {
**_TIER_MEDIUM,
"tool_progress": "off",
"long_running_notifications": False,
"busy_ack_detail": False,
},
"mattermost": _TIER_MEDIUM,
"matrix": _TIER_MEDIUM,
"feishu": _TIER_MEDIUM,
# Buzz (Nostr relay via buzz-cli): messages can be edited in place
# (`buzz messages edit`), so grouped/accumulating progress works, but
# channels are shared community spaces — keep the medium tier. Without
# this entry Buzz inherited the verbose _GLOBAL_DEFAULTS and every
# interim update became a separate permanent channel post (#95841).
"buzz": _TIER_MEDIUM,
# Tier 3 — no edit support, progress messages are permanent
"signal": _TIER_LOW,
"whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit
# WhatsApp Cloud API: Meta added message editing in 2023 but the
# Hermes Cloud adapter doesn't implement edit_message yet, so we
# stay on TIER_LOW (tool_progress off) to avoid spamming each
# status update as a separate message. Promote to TIER_MEDIUM once
# Cloud's edit_message lands.
"whatsapp_cloud": _TIER_LOW,
# Photon (managed iMessage over the gRPC sidecar) and BlueBubbles are both
# permanent-message iMessage inboxes with no message-edit support, so both
# stay TIER_LOW. This keeps tool progress, interim scratch commentary,
# "still working" heartbeats, and busy-ack iteration detail out of the
# user's iMessage thread. Without this entry Photon inherited the noisy
# global ("all") defaults and compacted/narrated on nearly every turn.
"photon": _TIER_LOW,
"bluebubbles": _TIER_LOW,
"weixin": _TIER_LOW,
# WeCom is technically non-editable but exposes a native streaming
# transport (msgtype: "stream" via aibot_respond_msg) that the gateway
# consumer routes mid-stream content through. Enable streaming by default
# so the WeCom client renders the typing animation and cumulative content
# updates instead of a single one-shot markdown drop.
"wecom": {**_TIER_LOW, "streaming": True},
"wecom_callback": _TIER_LOW,
"dingtalk": _TIER_LOW,
# Tier 4 — batch or non-interactive delivery
"email": _TIER_MINIMAL,
"sms": _TIER_MINIMAL,
"webhook": _TIER_MINIMAL,
"homeassistant": _TIER_MINIMAL,
"api_server": {**_TIER_HIGH, "tool_preview_length": 0},
}
# Canonical set of per-platform overrideable keys (for validation).
OVERRIDEABLE_KEYS = frozenset(_GLOBAL_DEFAULTS.keys())
def resolve_display_setting(
user_config: dict,
platform_key: str,
setting: str,
fallback: Any = None,
) -> Any:
"""Resolve a display setting with per-platform override support.
Parameters
----------
user_config : dict
The full parsed config.yaml dict.
platform_key : str
Platform config key (e.g. ``"telegram"``, ``"slack"``). Use
``_platform_config_key(source.platform)`` from gateway/run.py.
setting : str
Display setting name (e.g. ``"tool_progress"``, ``"show_reasoning"``).
fallback : Any
Fallback value when the setting isn't found anywhere.
Returns
-------
The resolved value, or *fallback* if nothing is configured.
"""
display_cfg = user_config.get("display") or {}
# 1. Explicit per-platform override (display.platforms.<platform>.<key>)
platforms = display_cfg.get("platforms") or {}
plat_overrides = platforms.get(platform_key)
if isinstance(plat_overrides, dict):
val = plat_overrides.get(setting)
if val is not None:
return _normalise(setting, val)
# 1b. Backward compat: display.tool_progress_overrides.<platform>
if setting == "tool_progress":
legacy = display_cfg.get("tool_progress_overrides")
if isinstance(legacy, dict):
val = legacy.get(platform_key)
if val is not None:
return _normalise(setting, val)
# 2. Global user setting (display.<key>). Skip display.streaming because
# that key controls only CLI terminal streaming; gateway token streaming is
# governed by the top-level streaming config plus per-platform overrides.
if setting != "streaming":
val = display_cfg.get(setting)
if val is not None:
return _normalise(setting, val)
# 3. Built-in platform default
plat_defaults = _PLATFORM_DEFAULTS.get(platform_key)
if plat_defaults:
val = plat_defaults.get(setting)
if val is not None:
return val
# 4. Built-in global default
val = _GLOBAL_DEFAULTS.get(setting)
if val is not None:
return val
return fallback
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _normalise(setting: str, value: Any) -> Any:
"""Normalise YAML quirks (bare ``off`` → False in YAML 1.1)."""
if setting == "tool_progress":
if value is False:
return "off"
if value is True:
return "all"
val = str(value).strip().lower()
if val in {"false", "0", "no"}:
return "off"
if val in {"true", "1", "yes", "on"}:
return "all"
return val if val in {"off", "new", "all", "verbose", "log"} else "all"
if setting in {
"show_reasoning",
"streaming",
"interim_assistant_messages",
"long_running_notifications",
"busy_ack_detail",
"busy_steer_ack_enabled",
"thinking_progress",
}:
if isinstance(value, str):
val = value.strip().lower()
if val == "generic" and setting == "long_running_notifications":
return "generic"
return val in {"true", "1", "yes", "on", "raw", "verbose"}
return bool(value)
if setting == "cleanup_progress":
if isinstance(value, str):
return value.lower() in {"true", "1", "yes", "on"}
return bool(value)
if setting == "live_status":
# Tri-state: "full" (verb + preview), "verb" (verb only), "off".
if value is True:
return "full"
if value is False:
return "off"
val = str(value).strip().lower()
if val in {"true", "1", "yes", "on", "all"}:
return "full"
if val in {"false", "0", "no"}:
return "off"
return val if val in {"full", "verb", "off"} else "full"
if setting == "tool_progress_grouping":
val = str(value).lower()
return val if val in ("accumulate", "separate") else "accumulate"
if setting == "reasoning_style":
val = str(value).lower()
return val if val in ("code", "blockquote", "subtext") else "code"
if setting == "tool_preview_length":
try:
return int(value)
except (TypeError, ValueError):
return 0
return value
+370
View File
@@ -0,0 +1,370 @@
"""External drain-control marker contract (dashboard → gateway).
Task 2.2 of the safe-shutdown plan (decisions.md Q-B, option A): the dashboard
has no way to call into a running gateway — there is no HTTP control channel
into the gateway process (guardrails: "there is NO external control channel
into a running gateway"). Restart/drain is driven only by the gateway reacting
to its own inputs: slash commands, process signals, and file markers it writes
itself (``.restart_notify.json``).
So the begin/cancel-drain dashboard endpoint communicates with the running
gateway the same way: it writes (or removes) a marker file, and a gateway
background watcher reacts to it. This module owns that marker contract so both
sides — the dashboard endpoint (writer) and the gateway watcher (reader) —
share one definition and can never disagree.
Contract (presence-based, mirroring ``.restart_notify.json``):
* begin-drain → write ``{HERMES_HOME}/.drain_request.json`` with
``{"action": "drain", "requested_at": <iso>, "principal": <str>,
"epoch": <instantiation-epoch>, "suppress_notification": <bool>}``.
* cancel-drain → remove the marker.
* The gateway watcher treats **presence of a marker stamped with the current
instantiation epoch** as "external drain active": flip
``gateway_state -> "draining"`` and stop accepting new turns. Absence (or a
marker from a *prior* instantiation) means "not draining" (revert to
``running`` if we had flipped it).
Why the epoch (NS-570). ``HERMES_HOME`` is a **durable** store — on Hermes
Cloud it is a persistent Fly volume (``/opt/data``). A begin-drain marker
written there *survives a machine restart*. But the disruptive lifecycle
actions a drain protects (auto-update / image migrate / env edit / profile
change) all **restart the machine**, which is exactly the signal that the drain
is over. Without the epoch, a freshly-restarted gateway re-reads the orphaned
marker on boot and parks itself right back in ``draining`` forever (NS-570: an
auto-updated instance refused every turn for ~52 min). Stamping the marker with
an identity of *this* container/VM instantiation, and ignoring a marker whose
epoch doesn't match, makes "a deliberate restart clears the drain" true by
construction — while a marker written during the *current* instantiation (the
live drain) still matches, and an s6 respawn of just the gateway (PID 1 / init
unchanged) still honours an in-flight drain.
Reading the marker never raises: a malformed/half-written file reads as
"present but contentless", which the watcher still treats as drain-active
(fail-safe toward quiescing — a corrupt begin marker must not be ignored). The
epoch check is deliberately **lenient**: it ignores a marker only on a
*definite* epoch mismatch. A marker with no epoch (legacy/corrupt/contentless),
or an environment where the epoch cannot be computed (non-Linux, no ``/proc``),
both degrade to the original presence-only behaviour — never fail-closed.
Why the max-age (#85433). The epoch handles the restart case, but it bakes in
the assumption that the action a drain protects always ends in a machine
restart. When a drain-gated action completes *without* recreating the container
and the writer never cancels the drain (writer crash, forgotten cleanup), the
orphaned marker still carries the *current* epoch — so the epoch check honours
it and the gateway bounces every inbound message forever (observed in the
field: a cloud instance refused all Telegram turns for ~3 days). The marker's
``requested_at`` timestamp is therefore also checked: a marker older than
:data:`DRAIN_REQUEST_MAX_AGE_SECONDS` reads as stale. Same leniency contract as
the epoch — only a *definite* expiry (timestamp present, parseable, and too
old) is ignored; a missing/corrupt timestamp still reads as drain-active. A
deliberately long drain has a sanctioned keep-alive: re-calling
:func:`write_drain_request` refreshes ``requested_at``.
"""
from __future__ import annotations
import functools
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from hermes_constants import get_hermes_home
from utils import atomic_json_write
_log = logging.getLogger(__name__)
_DRAIN_REQUEST_FILENAME = ".drain_request.json"
# Max-age fallback for a same-epoch orphaned marker (#85433). Drain-gated
# lifecycle actions complete in minutes; an hour is comfortably past any
# legitimate drain while still bounding the wedge a leaked marker can cause
# (vs. the unbounded outage observed in the field). Long-running drains
# refresh the marker via write_drain_request() (idempotent re-write bumps
# ``requested_at``) rather than raising this bound.
DRAIN_REQUEST_MAX_AGE_SECONDS = 3600.0
# Dedup guard for the expired-marker warning: the drain watcher re-reads the
# marker every second, and an expired orphan sits on disk until removed, so
# an unconditional warning would fire ~86k times/day. Keyed by the marker's
# ``requested_at`` string — a keep-alive re-write (new timestamp) that later
# expires again logs again, which is the desired behaviour.
_expiry_logged_for: Optional[str] = None
@functools.lru_cache(maxsize=1)
def current_instantiation_epoch() -> str:
"""Identity of THIS container / VM instantiation.
Stable for the life of the PID-1 init process — so an s6 respawn of just
the gateway keeps the same epoch and an in-flight drain is honoured — but
changes when the machine/container is recreated (a fresh PID 1 → a fresh
epoch). Composed from two ``/proc`` facts:
* the kernel **boot id** (``/proc/sys/kernel/random/boot_id``) — changes
on a VM / microVM reboot (e.g. a Fly Firecracker machine restart);
* **PID 1's start time** (field 22 of ``/proc/1/stat``) — changes on a
plain ``docker restart`` (the host kernel, hence boot_id, is unchanged,
but ``/init`` is a brand-new process).
Together they discriminate every restart mode that matters:
| event | boot_id | pid1 start | epoch | marker |
|--------------------------------|---------|------------|--------|--------|
| Fly microVM reboot (auto-upd.) | changes | changes | NEW | reject |
| plain ``docker restart`` | same | changes | NEW | reject |
| s6 respawn of the gateway only | same | same | SAME | honour |
| host ``hermes gateway restart``| same | same(init) | SAME | honour |
The last row is intentional: a host install has no durable-volume drain
bug, and honouring a drain across a deliberate process restart is the
intended reversible behaviour (D4a) — PID 1 there is the long-lived init
(systemd/launchd), so the epoch is stable.
Returns ``""`` when neither identity source is readable (non-Linux, no
``/proc``). An empty epoch disables the staleness check downstream,
degrading to the released presence-only behaviour — never fail-closed.
Memoised: the epoch is constant for the life of the process.
"""
boot_id = ""
try:
boot_id = (
Path("/proc/sys/kernel/random/boot_id")
.read_text(encoding="utf-8")
.strip()
)
except OSError:
pass
pid1_start = ""
try:
# /proc/1/stat: "<pid> (<comm>) <state> ... <starttime@field22> ...".
# comm can contain spaces and parens, so split on the LAST ')' and
# index into the whitespace-delimited tail. starttime is field 22
# (1-indexed); after the comm the tail starts at field 3, so it is the
# tail's index 19.
stat = Path("/proc/1/stat").read_text(encoding="utf-8")
tail = stat.rsplit(")", 1)[1].split()
pid1_start = tail[19]
except (OSError, IndexError):
pass
if not boot_id and not pid1_start:
return ""
return f"{boot_id}:{pid1_start}"
def drain_request_path(home: Optional[Path] = None) -> Path:
"""Absolute path to the drain-request marker, respecting HERMES_HOME."""
base = home if home is not None else get_hermes_home()
return Path(base) / _DRAIN_REQUEST_FILENAME
def write_drain_request(
*,
principal: str = "drain-control",
suppress_notification: bool = False,
home: Optional[Path] = None,
) -> dict[str, Any]:
"""Write the begin-drain marker. Returns the payload written.
Atomic write so the gateway watcher never reads a half-written file.
Idempotent: re-writing while a drain is already in progress refreshes
``requested_at`` — the sanctioned keep-alive for a drain that legitimately
needs longer than :data:`DRAIN_REQUEST_MAX_AGE_SECONDS`.
Stamps the marker with :func:`current_instantiation_epoch` so a marker that
later survives a machine restart on the durable HERMES_HOME volume can be
recognised as stale and ignored (NS-570).
``suppress_notification`` is a generic "be quiet on the shutdown that ends
this drain" flag. When the drain culminates in a process exit (e.g. NAS
recreates the machine for an auto-update image migration), the gateway's
shutdown path reads it via :func:`drain_notification_suppressed` and skips
the *home-channel* "gateway shutting down" broadcast — the operator-flavoured
ping that would otherwise fire on every routine auto-update, potentially
dozens of times a day. It NEVER suppresses the per-active-session interrupt
ping. The gateway stays agnostic about *why* the drain is quiet; the policy
of which drain causes set the flag lives entirely in the caller (NAS). The
field defaults False so legacy/operator drains behave exactly as before.
"""
payload = {
"action": "drain",
"requested_at": datetime.now(timezone.utc).isoformat(),
"principal": principal,
"epoch": current_instantiation_epoch(),
"suppress_notification": bool(suppress_notification),
}
atomic_json_write(drain_request_path(home), payload)
return payload
def clear_drain_request(*, home: Optional[Path] = None) -> bool:
"""Remove the drain marker (cancel-drain). Returns True if one existed.
Best-effort: a missing file is not an error (cancel is idempotent).
"""
path = drain_request_path(home)
try:
path.unlink()
return True
except FileNotFoundError:
return False
except OSError as e:
_log.warning("drain-control: failed to remove %s: %s", path, e)
return False
def _marker_epoch_is_stale(body: dict[str, Any]) -> bool:
"""True iff ``body``'s epoch is a *definite* mismatch with this process.
Lenient by design — returns False (i.e. "not stale, honour it") whenever it
can't be sure:
* the current epoch can't be computed ("" fallback, no /proc), OR
* the marker carries no epoch (legacy marker, or a corrupt/contentless
``{}`` body).
Only a marker whose epoch is present AND differs from the current
instantiation epoch is considered stale. This preserves the
fail-safe-toward-quiescing contract for malformed markers.
"""
current = current_instantiation_epoch()
if not current:
return False
marker_epoch = body.get("epoch")
if not marker_epoch:
return False
return marker_epoch != current
def _marker_is_expired(body: dict[str, Any]) -> bool:
"""True iff ``body``'s ``requested_at`` is *definitely* too old (#85433).
The max-age fallback for a same-epoch orphan: a drain-gated action that
completes WITHOUT a machine restart leaves a marker the epoch check cannot
reject, and if the writer never cancels the drain the gateway is wedged in
``draining`` for the life of the container. Bounding the marker's lifetime
by :data:`DRAIN_REQUEST_MAX_AGE_SECONDS` converts that unbounded outage
into a self-healing one.
Same leniency contract as :func:`_marker_epoch_is_stale` — returns False
("not expired, honour it") whenever it can't be sure:
* the marker carries no ``requested_at`` (legacy/corrupt/contentless
body), OR
* the timestamp isn't a parseable ISO-8601 string.
Only a timestamp that parses AND lies more than the max-age in the past is
considered expired. A future-dated timestamp (clock skew) is honoured. The
expiry is logged loudly — but once per marker, not per poll: the gateway's
drain watcher re-reads the marker every second, and an expired orphan stays
on disk until an operator or writer removes it, so an unconditional warning
would repeat ~86k times/day. This path only fires when a writer leaked a
marker, and the log is the operator's breadcrumb.
"""
global _expiry_logged_for
raw = body.get("requested_at")
if not isinstance(raw, str) or not raw:
return False
try:
requested_at = datetime.fromisoformat(raw)
except ValueError:
return False
if requested_at.tzinfo is None:
requested_at = requested_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - requested_at).total_seconds()
if age <= DRAIN_REQUEST_MAX_AGE_SECONDS:
return False
if _expiry_logged_for != raw:
_expiry_logged_for = raw
_log.warning(
"drain-control: ignoring expired drain marker (requested_at=%s, "
"age=%.0fs > max %.0fs, principal=%s) — the drain that wrote it "
"was never cancelled; treating as stale so the gateway keeps "
"accepting turns.",
raw,
age,
DRAIN_REQUEST_MAX_AGE_SECONDS,
body.get("principal"),
)
return True
def _marker_is_stale(body: dict[str, Any]) -> bool:
"""True iff the marker is definitely from a drain that is already over.
Two independent, individually-lenient signals (either suffices):
* epoch mismatch — the marker survived a machine restart (NS-570);
* expiry — a same-epoch orphan outlived any legitimate drain (#85433).
"""
return _marker_epoch_is_stale(body) or _marker_is_expired(body)
def drain_requested(*, home: Optional[Path] = None) -> bool:
"""True iff a begin-drain marker for THIS instantiation is present.
A marker whose ``epoch`` does not match the current instantiation epoch is
treated as absent: it survived a container/VM restart (HERMES_HOME is a
durable Fly volume on Hermes Cloud) and the lifecycle action that triggered
the drain has already completed — honouring it would wedge the
freshly-restarted gateway in ``draining`` (NS-570). A marker whose
``requested_at`` is older than :data:`DRAIN_REQUEST_MAX_AGE_SECONDS` is
likewise treated as absent: it is a same-epoch orphan whose drain-gated
action completed without a restart and was never cancelled (#85433). Both
staleness checks are lenient (see :func:`_marker_epoch_is_stale` /
:func:`_marker_is_expired`): a legacy/corrupt marker with no epoch and no
timestamp, or an environment without ``/proc``, still reads as
drain-active.
"""
body = read_drain_request(home=home)
if body is None:
return False
if _marker_is_stale(body):
return False
return True
def drain_notification_suppressed(*, home: Optional[Path] = None) -> bool:
"""True iff an ACTIVE drain marker asks to suppress the shutdown broadcast.
"Active" means exactly what :func:`drain_requested` means — a marker present
AND stamped with the current instantiation epoch AND not past its max-age.
A stale (other-epoch) marker that survived a machine restart on the durable
HERMES_HOME volume, or an expired same-epoch orphan (#85433), is
ignored here just as it is for drain state (NS-570): we must never let an
orphaned marker's flag silence a *fresh* gateway's legitimate shutdown
broadcast.
Only honours the flag when it is explicitly truthy in the marker body. A
legacy marker without the field, a corrupt/contentless ``{}`` body, or an
absent marker all read as "not suppressed" (False) — fail toward the louder,
more-visible behaviour, consistent with :func:`read_drain_request`'s
never-raise contract. The gateway's shutdown path uses this to skip ONLY the
home-channel broadcast; the per-active-session interrupt ping is unaffected.
"""
body = read_drain_request(home=home)
if body is None:
return False
if _marker_is_stale(body):
return False
return bool(body.get("suppress_notification"))
def read_drain_request(*, home: Optional[Path] = None) -> Optional[dict[str, Any]]:
"""Return the marker payload, or ``None`` if absent.
A present-but-unparseable marker returns ``{}`` (truthy-presence preserved
via :func:`drain_requested`; callers that need the body get an empty dict
rather than an exception). Never raises.
"""
path = drain_request_path(home)
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except OSError as e:
_log.warning("drain-control: failed to read %s: %s", path, e)
return None
try:
data = json.loads(raw)
except (ValueError, TypeError):
return {}
return data if isinstance(data, dict) else {}
+229
View File
@@ -0,0 +1,229 @@
"""
Event Hook System
A lightweight event-driven system that fires handlers at key lifecycle points.
Hooks are discovered from ~/.hermes/hooks/ directories, each containing:
- HOOK.yaml (metadata: name, description, events list)
- handler.py (Python handler with async def handle(event_type, context))
Events:
- gateway:startup -- Gateway process starts
- session:start -- New session created (first message of a new session)
- session:end -- Session ends (user ran /new or /reset)
- session:reset -- Session reset completed (new session entry created)
- agent:start -- Agent begins processing a message
- agent:step -- Each turn in the tool-calling loop
- agent:end -- Agent finishes processing
- command:* -- Any slash command executed (wildcard match)
Errors in hooks are caught and logged but never block the main pipeline.
Context dict passed to ``agent:start`` / ``agent:end`` handlers:
platform -- source platform name (e.g. "telegram", "matrix", "slack")
user_id -- platform user id of the sender
chat_id -- platform chat id (group/DM identifier)
thread_id -- Telegram forum-topic id / thread root id (string; empty
when not in a thread / topic)
chat_type -- "dm" | "group" | "forum" (empty if unknown)
session_id -- Hermes session id
message -- inbound message text (truncated to 500 chars)
``agent:end`` adds:
response -- agent response text (truncated to 500 chars)
model -- model name that handled the turn
provider -- provider that handled the turn
Handlers posting a follow-up into the same Telegram forum-topic should
include ``message_thread_id=int(thread_id)`` when ``chat_type == "forum"``
and ``thread_id`` is non-empty.
"""
import asyncio
import importlib.util
import sys
from typing import Any, Callable, Dict, List, Optional
import yaml
from hermes_cli.config import get_hermes_home
HOOKS_DIR = get_hermes_home() / "hooks"
class HookRegistry:
"""
Discovers, loads, and fires event hooks.
Usage:
registry = HookRegistry()
registry.discover_and_load()
await registry.emit("agent:start", {"platform": "telegram", ...})
"""
def __init__(self):
# event_type -> [handler_fn, ...]
self._handlers: Dict[str, List[Callable]] = {}
self._loaded_hooks: List[dict] = [] # metadata for listing
@property
def loaded_hooks(self) -> List[dict]:
"""Return metadata about all loaded hooks."""
return list(self._loaded_hooks)
def _register_builtin_hooks(self) -> None:
"""Register built-in hooks that are always active.
Currently empty — no shipped built-in hooks. Kept as the extension
point for future always-on gateway hooks so they drop in without
re-plumbing discover_and_load().
"""
return
def discover_and_load(self) -> None:
"""
Scan the hooks directory for hook directories and load their handlers.
Also registers built-in hooks that are always active.
Each hook directory must contain:
- HOOK.yaml with at least 'name' and 'events' keys
- handler.py with a top-level 'handle' function (sync or async)
"""
self._register_builtin_hooks()
if not HOOKS_DIR.exists():
return
for hook_dir in sorted(HOOKS_DIR.iterdir()):
if not hook_dir.is_dir():
continue
manifest_path = hook_dir / "HOOK.yaml"
handler_path = hook_dir / "handler.py"
if not manifest_path.exists() or not handler_path.exists():
continue
try:
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
if not manifest or not isinstance(manifest, dict):
print(f"[hooks] Skipping {hook_dir.name}: invalid HOOK.yaml", flush=True)
continue
hook_name = manifest.get("name", hook_dir.name)
events = manifest.get("events", [])
if not events:
print(f"[hooks] Skipping {hook_name}: no events declared", flush=True)
continue
# Dynamically load the handler module.
# Register in sys.modules BEFORE exec_module so Pydantic /
# dataclasses / typing introspection can resolve forward
# references (triggered by `from __future__ import annotations`
# in the handler). Without this, a handler that declares a
# Pydantic BaseModel for webhook/event payloads fails at first
# dispatch with "TypeAdapter ... is not fully defined".
module_name = f"hermes_hook_{hook_name}"
spec = importlib.util.spec_from_file_location(
module_name, handler_path
)
if spec is None or spec.loader is None:
print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True)
continue
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(module_name, None)
raise
handle_fn = getattr(module, "handle", None)
if handle_fn is None:
print(f"[hooks] Skipping {hook_name}: no 'handle' function found", flush=True)
continue
# Register the handler for each declared event
for event in events:
self._handlers.setdefault(event, []).append(handle_fn)
self._loaded_hooks.append({
"name": hook_name,
"description": manifest.get("description", ""),
"events": events,
"path": str(hook_dir),
})
print(f"[hooks] Loaded hook '{hook_name}' for events: {events}", flush=True)
except Exception as e:
print(f"[hooks] Error loading hook {hook_dir.name}: {e}", flush=True)
def _resolve_handlers(self, event_type: str) -> List[Callable]:
"""Return all handlers that should fire for ``event_type``.
Exact matches fire first, followed by wildcard matches (e.g.
``command:*`` matches ``command:reset``).
"""
handlers = list(self._handlers.get(event_type, []))
if ":" in event_type:
base = event_type.split(":")[0]
wildcard_key = f"{base}:*"
handlers.extend(self._handlers.get(wildcard_key, []))
return handlers
async def emit(self, event_type: str, context: Optional[Dict[str, Any]] = None) -> None:
"""
Fire all handlers registered for an event, discarding return values.
Supports wildcard matching: handlers registered for "command:*" will
fire for any "command:..." event. Handlers registered for a base type
like "agent" won't fire for "agent:start" -- only exact matches and
explicit wildcards.
Args:
event_type: The event identifier (e.g. "agent:start").
context: Optional dict with event-specific data.
"""
if context is None:
context = {}
for fn in self._resolve_handlers(event_type):
try:
result = fn(event_type, context)
# Support both sync and async handlers
if asyncio.iscoroutine(result):
await result
except Exception as e:
print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
async def emit_collect(
self,
event_type: str,
context: Optional[Dict[str, Any]] = None,
) -> List[Any]:
"""Fire handlers and return their non-None return values in order.
Like :meth:`emit` but captures each handler's return value. Used for
decision-style hooks (e.g. ``command:<name>`` policies that want to
allow/deny/rewrite the command before normal dispatch).
Exceptions from individual handlers are logged but do not abort the
remaining handlers.
"""
if context is None:
context = {}
results: List[Any] = []
for fn in self._resolve_handlers(event_type):
try:
result = fn(event_type, context)
if asyncio.iscoroutine(result):
result = await result
if result is not None:
results.append(result)
except Exception as e:
print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
return results
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+190
View File
@@ -0,0 +1,190 @@
"""Target-issued execution authority for RoomLink member turns."""
from __future__ import annotations
import hashlib
import json
import re
from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import Any, Mapping
POLICY_VERSION = 1
MAX_POLICY_TOOLSETS = 128
MAX_POLICY_ITERATIONS = (1 << 53) - 1
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
class RoomExecutionPolicyError(ValueError):
"""A RoomLink execution policy is malformed or no longer current."""
def _canonical_json(value: Mapping[str, Any]) -> bytes:
return json.dumps(
value,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode("ascii")
def _identifier(value: Any, *, field: str) -> str:
normalized = str(value or "").strip()
if (
not normalized
or len(normalized) > 128
or _IDENTIFIER_RE.fullmatch(normalized) is None
):
raise RoomExecutionPolicyError(f"{field} is invalid")
return normalized
@dataclass(frozen=True)
class RoomExecutionPolicy:
"""Immutable target policy applied at the agent and approval boundaries."""
version: int
target_profile: str
enabled_toolsets: tuple[str, ...]
approval_mode: str
max_iterations: int
policy_digest: str
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "RoomExecutionPolicy":
required = {
"version",
"target_profile",
"enabled_toolsets",
"approval_mode",
"max_iterations",
"policy_digest",
}
if not isinstance(value, Mapping) or set(value) != required:
raise RoomExecutionPolicyError("execution policy fields are invalid")
if value["version"] != POLICY_VERSION:
raise RoomExecutionPolicyError("execution policy version is unsupported")
target_profile = _identifier(value["target_profile"], field="target_profile")
raw_toolsets = value["enabled_toolsets"]
if (
not isinstance(raw_toolsets, list)
or not raw_toolsets
or len(raw_toolsets) > MAX_POLICY_TOOLSETS
):
raise RoomExecutionPolicyError("enabled_toolsets are invalid")
toolsets = tuple(
sorted(_identifier(item, field="enabled_toolset") for item in raw_toolsets)
)
if len(set(toolsets)) != len(toolsets) or "bot_room" not in toolsets:
raise RoomExecutionPolicyError("enabled_toolsets are invalid")
approval_mode = str(value["approval_mode"] or "").strip().lower()
if approval_mode not in {"manual", "smart", "off"}:
raise RoomExecutionPolicyError("approval_mode is invalid")
max_iterations = value["max_iterations"]
if (
isinstance(max_iterations, bool)
or not isinstance(max_iterations, int)
or not 1 <= max_iterations <= MAX_POLICY_ITERATIONS
):
raise RoomExecutionPolicyError("max_iterations is invalid")
unsigned = {
"version": POLICY_VERSION,
"target_profile": target_profile,
"enabled_toolsets": list(toolsets),
"approval_mode": approval_mode,
"max_iterations": max_iterations,
}
expected = hashlib.sha256(_canonical_json(unsigned)).hexdigest()
supplied = str(value["policy_digest"] or "").strip().lower()
if supplied != expected:
raise RoomExecutionPolicyError(
"policy_digest does not match the execution policy"
)
return cls(**unsigned, policy_digest=supplied)
def as_mapping(self) -> dict[str, Any]:
return {
"version": self.version,
"target_profile": self.target_profile,
"enabled_toolsets": list(self.enabled_toolsets),
"approval_mode": self.approval_mode,
"max_iterations": self.max_iterations,
"policy_digest": self.policy_digest,
}
def execution_policy_mapping(
*,
target_profile: str,
config: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Resolve the effective API-server policy from the target's own config."""
if config is None:
from gateway.run import _load_gateway_config
config = _load_gateway_config()
if not isinstance(config, Mapping):
raise RoomExecutionPolicyError("gateway config is invalid")
from hermes_cli.config import resolve_turn_limit
from hermes_cli.tools_config import _get_platform_tools
from tools.approval import _YOLO_MODE_FROZEN, _normalize_approval_mode
toolsets = sorted({*_get_platform_tools(dict(config), "api_server"), "bot_room"})
agent = config.get("agent") if isinstance(config.get("agent"), Mapping) else {}
approvals = (
config.get("approvals") if isinstance(config.get("approvals"), Mapping) else {}
)
max_iterations = min(
resolve_turn_limit(agent.get("max_turns")),
MAX_POLICY_ITERATIONS,
)
approval_mode = (
"off"
if _YOLO_MODE_FROZEN
else _normalize_approval_mode(approvals.get("mode", "manual"))
)
unsigned = {
"version": POLICY_VERSION,
"target_profile": _identifier(target_profile, field="target_profile"),
"enabled_toolsets": toolsets,
"approval_mode": approval_mode,
"max_iterations": max_iterations,
}
value = {
**unsigned,
"policy_digest": hashlib.sha256(_canonical_json(unsigned)).hexdigest(),
}
return RoomExecutionPolicy.from_mapping(value).as_mapping()
_CURRENT_POLICY: ContextVar[RoomExecutionPolicy | None] = ContextVar(
"hosted_room_execution_policy",
default=None,
)
def bind_room_execution_policy(policy: RoomExecutionPolicy) -> Token:
return _CURRENT_POLICY.set(policy)
def reset_room_execution_policy(token: Token) -> None:
_CURRENT_POLICY.reset(token)
def current_room_execution_policy() -> RoomExecutionPolicy | None:
return _CURRENT_POLICY.get()
__all__ = [
"MAX_POLICY_ITERATIONS",
"POLICY_VERSION",
"RoomExecutionPolicy",
"RoomExecutionPolicyError",
"bind_room_execution_policy",
"current_room_execution_policy",
"execution_policy_mapping",
"reset_room_execution_policy",
]
+249
View File
@@ -0,0 +1,249 @@
"""Private SQLite storage for negotiated hosted-room links.
Route metadata and its scoped grant share the gateway's private root
``state.db``. SQLite WAL plus ``BEGIN IMMEDIATE`` owns concurrency; grants are
never included in reprs, status payloads, or exception messages.
"""
from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Mapping
from gateway import hosted_rooms
from gateway.hosted_room_peer import (
GatewayRoomCatalog,
HostedRoomPeerError,
TransportSecurity,
validate_room_link_url,
)
MAX_LINKS = 512
MAX_GRANT_CHARS = 16 * 1024
_LEGACY_FIELDS = {
"room_id",
"member_id",
"target_url",
"target_profile",
"grant",
"catalog",
"cancellation_scope_id",
"trace_id",
"updated_at",
}
_STATUSES = {"ready", "unavailable", "needs_reauthorization"}
@dataclass(frozen=True)
class StoredRoomLink:
room_id: str
member_id: str
target_url: str
target_profile: str
grant: str = field(repr=False)
catalog: GatewayRoomCatalog
cancellation_scope_id: str
trace_id: str
transport_security: TransportSecurity
status: str
updated_at: float
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "StoredRoomLink":
allowed = _LEGACY_FIELDS | {"transport_security", "status"}
if set(value) - allowed or not _LEGACY_FIELDS.issubset(value):
raise HostedRoomPeerError("stored room link fields are invalid")
room_id = _short_string(value["room_id"], "room_id")
member_id = _short_string(value["member_id"], "member_id")
target_profile = _short_string(value["target_profile"], "target_profile")
target_url, detected_security = validate_room_link_url(value["target_url"])
transport_security = str(value.get("transport_security") or detected_security)
if transport_security != detected_security:
raise HostedRoomPeerError("transport_security does not match target_url")
grant = str(value["grant"] or "")
if not grant or len(grant) > MAX_GRANT_CHARS:
raise HostedRoomPeerError("room grant is missing or too large")
status = str(value.get("status") or "ready")
if status not in _STATUSES:
raise HostedRoomPeerError("stored room link status is invalid")
updated_at = float(value["updated_at"])
if not updated_at > 0:
raise HostedRoomPeerError("updated_at must be positive")
return cls(
room_id=room_id,
member_id=member_id,
target_url=target_url,
target_profile=target_profile,
grant=grant,
catalog=GatewayRoomCatalog.from_mapping(value["catalog"]),
cancellation_scope_id=_short_string(
value["cancellation_scope_id"], "cancellation_scope_id"
),
trace_id=_short_string(value["trace_id"], "trace_id"),
transport_security=transport_security, # type: ignore[arg-type]
status=status,
updated_at=updated_at,
)
@classmethod
def from_record(cls, value: Mapping[str, Any]) -> "StoredRoomLink":
try:
catalog = json.loads(str(value["catalog_json"]))
except Exception as exc:
raise HostedRoomPeerError("stored room link catalog is unreadable") from exc
return cls.from_mapping({
"room_id": value["room_id"],
"member_id": value["member_id"],
"target_url": value["target_url"],
"target_profile": value["target_profile"],
"grant": value["grant"],
"catalog": catalog,
"cancellation_scope_id": value["cancellation_scope_id"],
"trace_id": value["trace_id"],
"transport_security": value["transport_security"],
"status": value["status"],
"updated_at": value["updated_at"],
})
def catalog_mapping(self) -> dict[str, Any]:
value = {
"installation_id": self.catalog.installation_id,
"protocol_versions": list(self.catalog.protocol_versions),
"link_modes": list(self.catalog.link_modes),
"persistent_process": self.catalog.persistent_process,
"text": self.catalog.text,
"attachments": self.catalog.attachments,
"execution_policy": self.catalog.execution_policy.as_mapping(),
"catalog_digest": self.catalog.catalog_digest,
}
if (
self.catalog.endpoint_url is not None
or self.catalog.endpoint_reason is not None
):
value["endpoint"] = self.catalog.endpoint_mapping()
return value
def as_record(self) -> dict[str, Any]:
return {
"room_id": self.room_id,
"member_id": self.member_id,
"target_url": self.target_url,
"target_profile": self.target_profile,
"grant": self.grant,
"catalog_json": json.dumps(
self.catalog_mapping(), sort_keys=True, separators=(",", ":")
),
"cancellation_scope_id": self.cancellation_scope_id,
"trace_id": self.trace_id,
"transport_security": self.transport_security,
"status": self.status,
"updated_at": self.updated_at,
}
def _short_string(value: Any, field: str) -> str:
normalized = str(value or "").strip()
if not normalized or len(normalized) > 256:
raise HostedRoomPeerError(f"{field} is invalid")
return normalized
def load_room_links(db_path: Path | str) -> tuple[StoredRoomLink, ...]:
rows = hosted_rooms.list_room_link_records(db_path)
if len(rows) > MAX_LINKS:
raise HostedRoomPeerError("stored room link list is invalid")
return tuple(StoredRoomLink.from_record(row) for row in rows)
def load_room_links_tolerant(
db_path: Path | str,
) -> tuple[tuple[StoredRoomLink, ...], tuple[str, ...]]:
"""Load healthy routes while quarantining malformed rows by identity."""
rows = hosted_rooms.list_room_link_records(db_path)
if len(rows) > MAX_LINKS:
raise HostedRoomPeerError("stored room link list is invalid")
links = []
errors = []
for row in rows:
try:
links.append(StoredRoomLink.from_record(row))
except Exception:
room = str(row.get("room_id") or "unknown")
member = str(row.get("member_id") or "unknown")
errors.append(f"{room}:{member}:invalid")
return tuple(links), tuple(errors)
def save_room_link(db_path: Path | str, link: StoredRoomLink) -> None:
hosted_rooms.upsert_room_link_record(
db_path, record=link.as_record(), max_links=MAX_LINKS
)
if os.name == "posix":
try:
Path(db_path).chmod(0o600)
except OSError:
pass
def mark_room_link_status(
db_path: Path | str,
*,
room_id: str,
member_id: str,
status: str,
) -> bool:
if status not in _STATUSES:
raise HostedRoomPeerError("stored room link status is invalid")
return hosted_rooms.update_room_link_status(
db_path,
room_id=_short_string(room_id, "room_id"),
member_id=_short_string(member_id, "member_id"),
status=status,
)
def make_stored_link(
*,
room_id: str,
member_id: str,
target_url: str,
target_profile: str,
grant: str,
catalog: GatewayRoomCatalog,
cancellation_scope_id: str,
trace_id: str,
) -> StoredRoomLink:
target_url, transport_security = validate_room_link_url(target_url)
return StoredRoomLink.from_mapping({
"room_id": room_id,
"member_id": member_id,
"target_url": target_url,
"target_profile": target_profile,
"grant": grant,
"catalog": {
"installation_id": catalog.installation_id,
"protocol_versions": list(catalog.protocol_versions),
"link_modes": list(catalog.link_modes),
"persistent_process": catalog.persistent_process,
"text": catalog.text,
"attachments": catalog.attachments,
"execution_policy": catalog.execution_policy.as_mapping(),
"catalog_digest": catalog.catalog_digest,
**(
{"endpoint": catalog.endpoint_mapping()}
if catalog.endpoint_url is not None
or catalog.endpoint_reason is not None
else {}
),
},
"cancellation_scope_id": cancellation_scope_id,
"trace_id": trace_id,
"transport_security": transport_security,
"status": "ready",
"updated_at": time.time(),
})
+887
View File
@@ -0,0 +1,887 @@
"""Typed contracts for autonomous cross-gateway hosted-room members.
The Desktop may bootstrap an invitation, but it is never the issuer or the
runtime courier. The target gateway verifies a scoped grant and the full task
coordinates before it admits any model or tool work.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import ipaddress
import json
import math
import os
import re
import stat
import time
import urllib.parse
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable, Literal, Mapping
from gateway.hosted_room_execution_policy import (
RoomExecutionPolicy,
execution_policy_mapping,
)
# Version 2 adds authority/member lineage to scoped grants. It is intentionally
# not wire-compatible with the unpublished v1 draft; mixed gateways must fall
# back to Desktop-driven rooms instead of accepting a weaker token shape.
PROTOCOL_VERSION = 2
MAX_TOKEN_BYTES = 16 * 1024
MAX_PROMPT_BYTES = 256 * 1024
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$")
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
_LINK_PRIORITY = {
"direct": 0,
"overlay": 1,
"relay": 2,
"pull": 3,
"desktop": 4,
}
LinkMode = Literal["direct", "overlay", "relay", "pull", "desktop"]
TransportSecurity = Literal["tls", "loopback"]
class HostedRoomPeerError(ValueError):
"""Base error for malformed or unauthorized peer-room input."""
class HostedRoomGrantError(HostedRoomPeerError):
"""Raised when a room-scoped grant is invalid or expired."""
_ROOM_GRANT_SECRET_FILE = ".room-link-grant-secret"
@lru_cache(maxsize=32)
def _gateway_room_grant_secret_for_home(home_value: str) -> bytes:
"""Load one restart-scoped grant secret for an exact installation root."""
home = Path(home_value)
home.mkdir(parents=True, exist_ok=True)
path = home / _ROOM_GRANT_SECRET_FILE
def _read() -> bytes:
data = path.read_bytes()
if len(data) != 32:
raise HostedRoomGrantError("gateway RoomLink secret is invalid")
mode = stat.S_IMODE(path.stat().st_mode)
if mode & 0o077:
path.chmod(0o600)
return data
try:
material = _read()
except FileNotFoundError:
material = os.urandom(32)
temporary = home / (
f".{_ROOM_GRANT_SECRET_FILE}.{os.getpid()}.{os.urandom(8).hex()}"
)
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd, "wb", closefd=True) as stream:
stream.write(material)
stream.flush()
os.fsync(stream.fileno())
try:
os.link(temporary, path)
except FileExistsError:
material = _read()
else:
try:
parent_fd = os.open(home, os.O_RDONLY)
try:
os.fsync(parent_fd)
finally:
os.close(parent_fd)
except OSError:
pass
finally:
temporary.unlink(missing_ok=True)
return hmac.new(
material,
b"hermes-hosted-room-installation-grant-v1",
hashlib.sha256,
).digest()
def gateway_room_grant_secret(root: Path | str | None = None) -> bytes:
"""Load or atomically mint the gateway-only RoomLink signing secret.
API keys are bearer credentials known to clients and may be profile scoped;
they must never become grant-signing authority. This secret lives in the
installation root, is not exposed by configuration or capability RPCs, and
is shared only by the gateway processes that serve this installation.
"""
if root is None:
from hermes_constants import get_hermes_home
# Profile routing uses a context-local HERMES_HOME override. The process
# environment retains the installation root and is the authority here.
root = os.environ.get("HERMES_HOME") or get_hermes_home()
home = Path(root).expanduser().resolve()
return _gateway_room_grant_secret_for_home(str(home))
def derive_room_grant_secret(api_key: str) -> bytes:
"""Domain-separate room grants from the configured API key.
The API-server startup guard enforces the production key-strength policy;
this helper keeps a lower structural floor for isolated contract tests.
"""
if not isinstance(api_key, str) or len(api_key) < 8:
raise HostedRoomGrantError("room grants require a strong gateway API key")
return hmac.new(
api_key.encode("utf-8"),
b"hermes-hosted-room-grant-v1",
hashlib.sha256,
).digest()
def _identifier(value: Any, *, field: str) -> str:
if not isinstance(value, str):
raise HostedRoomPeerError(f"{field} must be a string")
normalized = value.strip()
if not _IDENTIFIER_RE.fullmatch(normalized):
raise HostedRoomPeerError(f"{field} is invalid")
return normalized
def _positive_int(value: Any, *, field: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise HostedRoomPeerError(f"{field} must be a positive integer")
return value
def _digest(value: Any, *, field: str) -> str:
if not isinstance(value, str) or not _DIGEST_RE.fullmatch(value):
raise HostedRoomPeerError(f"{field} must be a sha256 digest")
return value
def _exact_fields(
value: Mapping[str, Any],
*,
required: set[str],
optional: set[str] | None = None,
label: str,
) -> None:
optional = optional or set()
fields = set(value)
missing = required - fields
unknown = fields - required - optional
if missing:
raise HostedRoomPeerError(
f"{label} missing fields: {', '.join(sorted(missing))}"
)
if unknown:
raise HostedRoomPeerError(
f"{label} unknown fields: {', '.join(sorted(unknown))}"
)
def _canonical_json(value: Mapping[str, Any]) -> bytes:
return json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("ascii")
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _b64decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
try:
return base64.urlsafe_b64decode(value + padding)
except Exception as exc:
raise HostedRoomGrantError("room grant encoding is invalid") from exc
@dataclass(frozen=True)
class GatewayRoomCatalog:
"""Authenticated gateway capabilities inherited by its Bots."""
installation_id: str
protocol_versions: tuple[int, ...]
link_modes: tuple[LinkMode, ...]
persistent_process: bool
text: bool
attachments: bool
execution_policy: RoomExecutionPolicy
catalog_digest: str
endpoint_url: str | None = None
endpoint_reason: str | None = None
transport_security: TransportSecurity | None = None
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "GatewayRoomCatalog":
_exact_fields(
value,
required={
"installation_id",
"protocol_versions",
"link_modes",
"persistent_process",
"text",
"attachments",
"execution_policy",
"catalog_digest",
},
optional={"endpoint"},
label="capability catalog",
)
installation_id = _identifier(value["installation_id"], field="installation_id")
versions_raw = value["protocol_versions"]
if not isinstance(versions_raw, list) or not versions_raw:
raise HostedRoomPeerError("protocol_versions must be a non-empty list")
versions = tuple(
sorted({
_positive_int(item, field="protocol_version") for item in versions_raw
})
)
links_raw = value["link_modes"]
if not isinstance(links_raw, list) or not links_raw:
raise HostedRoomPeerError("link_modes must be a non-empty list")
links: list[LinkMode] = []
for item in links_raw:
if item not in _LINK_PRIORITY:
raise HostedRoomPeerError("link_modes contains an unsupported mode")
if item not in links:
links.append(item)
for field in ("persistent_process", "text", "attachments"):
if not isinstance(value[field], bool):
raise HostedRoomPeerError(f"{field} must be a boolean")
unsigned = {
"installation_id": installation_id,
"protocol_versions": list(versions),
"link_modes": links,
"persistent_process": value["persistent_process"],
"text": value["text"],
"attachments": value["attachments"],
"execution_policy": RoomExecutionPolicy.from_mapping(
value["execution_policy"]
).as_mapping(),
}
endpoint_url = None
endpoint_reason = None
transport_security = None
if "endpoint" in value:
endpoint = value["endpoint"]
if not isinstance(endpoint, Mapping) or not isinstance(
endpoint.get("available"), bool
):
raise HostedRoomPeerError("endpoint capability is invalid")
if endpoint["available"]:
_exact_fields(
endpoint,
required={"available", "url", "transport_security"},
label="endpoint capability",
)
endpoint_url, transport_security = validate_room_link_url(
endpoint["url"]
)
if endpoint["transport_security"] != transport_security:
raise HostedRoomPeerError(
"endpoint transport_security does not match its URL"
)
normalized_endpoint = {
"available": True,
"url": endpoint_url,
"transport_security": transport_security,
}
else:
_exact_fields(
endpoint,
required={"available", "reason"},
label="endpoint capability",
)
endpoint_reason = _identifier(
endpoint["reason"], field="endpoint.reason"
)
normalized_endpoint = {
"available": False,
"reason": endpoint_reason,
}
unsigned["endpoint"] = normalized_endpoint
expected = hashlib.sha256(_canonical_json(unsigned)).hexdigest()
supplied = _digest(value["catalog_digest"], field="catalog_digest")
if not hmac.compare_digest(expected, supplied):
raise HostedRoomPeerError("catalog_digest does not match the catalog")
return cls(
installation_id=installation_id,
protocol_versions=versions,
link_modes=tuple(links),
persistent_process=value["persistent_process"],
text=value["text"],
attachments=value["attachments"],
execution_policy=RoomExecutionPolicy.from_mapping(
value["execution_policy"]
),
catalog_digest=supplied,
endpoint_url=endpoint_url,
endpoint_reason=endpoint_reason,
transport_security=transport_security,
)
def endpoint_mapping(self) -> dict[str, Any]:
"""Return the normalized self-advertised endpoint capability."""
if self.endpoint_url is not None:
return {
"available": True,
"url": self.endpoint_url,
"transport_security": self.transport_security,
}
return {
"available": False,
"reason": self.endpoint_reason or "not_configured",
}
def catalog_mapping(
*,
installation_id: str,
protocol_versions: Iterable[int] = (PROTOCOL_VERSION,),
link_modes: Iterable[LinkMode] = ("direct", "pull"),
persistent_process: bool,
text: bool = True,
attachments: bool = False,
endpoint: Mapping[str, Any] | None = None,
target_profile: str | None = None,
execution_policy: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a canonical catalog mapping with its digest."""
# A Desktop-managed gateway exits with the app. Treat the caller's flag
# as an upper bound so every local catalog construction site stays honest,
# including older call sites that still pass ``True`` explicitly.
persistent_process = bool(persistent_process and os.getenv("HERMES_DESKTOP") != "1")
checked_policy = RoomExecutionPolicy.from_mapping(
execution_policy
or execution_policy_mapping(
target_profile=(
str(target_profile or "").strip()
or (os.getenv("HERMES_PROFILE") or "default").strip()
or "default"
)
)
)
# A RoomLink run is initiated by another installation. Process-wide YOLO
# mode bypasses the scoped approval ContextVar, so it cannot be made safe by
# rewriting the advertised policy. Refuse to advertise or accept remote
# room execution until the target enables manual or smart approvals.
if checked_policy.approval_mode == "off":
raise HostedRoomPeerError(
"remote room execution requires manual or smart approvals"
)
value = {
"installation_id": _identifier(installation_id, field="installation_id"),
"protocol_versions": sorted({
_positive_int(item, field="protocol_version") for item in protocol_versions
}),
# Direct HTTPS/loopback is the only RoomLink transport implemented by
# this backend slice. Do not advertise pull/relay placeholders.
"link_modes": [mode for mode in dict.fromkeys(link_modes) if mode == "direct"],
"persistent_process": bool(persistent_process),
"text": bool(text),
"attachments": bool(attachments),
"execution_policy": checked_policy.as_mapping(),
}
value["endpoint"] = dict(
local_room_link_endpoint() if endpoint is None else endpoint
)
value["catalog_digest"] = hashlib.sha256(_canonical_json(value)).hexdigest()
GatewayRoomCatalog.from_mapping(value)
return value
def local_catalog_mapping(
*,
installation_id: str,
protocol_versions: Iterable[int] = (PROTOCOL_VERSION,),
link_modes: Iterable[LinkMode] = ("direct", "pull"),
text: bool = True,
attachments: bool = False,
target_profile: str | None = None,
execution_policy: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Build the one truthful catalog advertised by this local process."""
return catalog_mapping(
installation_id=installation_id,
protocol_versions=protocol_versions,
link_modes=link_modes,
persistent_process=True,
text=text,
attachments=attachments,
endpoint=local_room_link_endpoint(),
target_profile=target_profile,
execution_policy=execution_policy,
)
def local_room_link_endpoint(value: Any | None = None) -> dict[str, Any]:
"""Return the validated endpoint this gateway explicitly advertises."""
configured = _configured_room_link_url() if value is None else value
if not str(configured or "").strip():
return {"available": False, "reason": "not_configured"}
try:
url, transport_security = validate_room_link_url(configured)
except HostedRoomPeerError:
return {"available": False, "reason": "invalid_configuration"}
return {
"available": True,
"url": url,
"transport_security": transport_security,
}
@lru_cache(maxsize=16)
def _room_link_url_from_config(home: str) -> str | None:
"""Read the restart-scoped user setting without polling config on probes."""
from gateway.config import load_gateway_config
from hermes_constants import (
get_hermes_home,
reset_hermes_home_override,
set_hermes_home_override,
)
if str(get_hermes_home()) == home:
value = load_gateway_config().room_link_url
else:
token = set_hermes_home_override(home)
try:
value = load_gateway_config().room_link_url
finally:
reset_hermes_home_override(token)
return value.strip() if isinstance(value, str) and value.strip() else None
def _configured_room_link_url() -> str | None:
"""Resolve the explicit endpoint with environment override precedence."""
override = os.getenv("HERMES_ROOM_LINK_URL")
if override is not None:
return override
from hermes_constants import get_default_hermes_root, get_hermes_home
home = get_hermes_home()
configured = _room_link_url_from_config(str(home))
if configured:
return configured
# RoomLink is a gateway reachability property, not a Bot personality
# setting. Named profiles may override it, but otherwise inherit the
# process gateway's root endpoint so adding a Bot does not require
# repeating network configuration in every profile.
root = get_default_hermes_root()
if root != home:
return _room_link_url_from_config(str(root))
return None
def validate_room_link_url(value: Any) -> tuple[str, TransportSecurity]:
"""Validate a RoomLink endpoint and classify its transport protection.
Scoped grants and room prompts may use plaintext HTTP only when the peer
is reached through the local loopback interface. Every non-loopback
endpoint must use HTTPS.
"""
raw = str(value or "").strip().rstrip("/")
try:
parsed = urllib.parse.urlsplit(raw)
hostname = (parsed.hostname or "").rstrip(".").lower()
# Force urllib to validate a malformed/out-of-range port.
parsed.port
except ValueError as exc:
raise HostedRoomPeerError("target_url is invalid") from exc
if not hostname or parsed.username is not None or parsed.password is not None:
raise HostedRoomPeerError("target_url is invalid")
if parsed.query or parsed.fragment:
raise HostedRoomPeerError("target_url must not include query or fragment")
if parsed.scheme.lower() == "https":
return raw, "tls"
if parsed.scheme.lower() != "http":
raise HostedRoomPeerError("target_url must use https")
loopback = hostname == "localhost" or hostname.endswith(".localhost")
if not loopback:
try:
loopback = ipaddress.ip_address(hostname).is_loopback
except ValueError:
loopback = False
if not loopback:
raise HostedRoomPeerError("target_url must use https outside the local machine")
return raw, "loopback"
@dataclass(frozen=True)
class HostedMemberDispatch:
"""Recipient-validated identity for one remote room member attempt."""
protocol_version: int
room_id: str
home_install_id: str
authority_gateway_id: str
authority_epoch: int
member_id: str
target_install_id: str
target_profile: str
task_id: str
execution_generation: int
source_event_seq: int
cancellation_scope_id: str
prompt: str
prompt_digest: str
capability_digest: str
execution_policy_digest: str
trace_id: str
def as_mapping(self) -> dict[str, Any]:
"""Return the canonical wire mapping used for fingerprinting."""
return {
"protocol_version": self.protocol_version,
"room_id": self.room_id,
"home_install_id": self.home_install_id,
"authority_gateway_id": self.authority_gateway_id,
"authority_epoch": self.authority_epoch,
"member_id": self.member_id,
"target_install_id": self.target_install_id,
"target_profile": self.target_profile,
"task_id": self.task_id,
"execution_generation": self.execution_generation,
"source_event_seq": self.source_event_seq,
"cancellation_scope_id": self.cancellation_scope_id,
"prompt": self.prompt,
"prompt_digest": self.prompt_digest,
"capability_digest": self.capability_digest,
"execution_policy_digest": self.execution_policy_digest,
"trace_id": self.trace_id,
}
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "HostedMemberDispatch":
required = {
"protocol_version",
"room_id",
"home_install_id",
"authority_gateway_id",
"authority_epoch",
"member_id",
"target_install_id",
"target_profile",
"task_id",
"execution_generation",
"source_event_seq",
"cancellation_scope_id",
"prompt",
"prompt_digest",
"capability_digest",
"execution_policy_digest",
"trace_id",
}
_exact_fields(value, required=required, label="dispatch")
prompt = value["prompt"]
if not isinstance(prompt, str) or not prompt.strip():
raise HostedRoomPeerError("prompt must be a non-empty string")
if len(prompt.encode("utf-8")) > MAX_PROMPT_BYTES:
raise HostedRoomPeerError("prompt is too large")
expected_prompt_digest = hashlib.sha256(prompt.encode("utf-8")).hexdigest()
prompt_digest = _digest(value["prompt_digest"], field="prompt_digest")
if not hmac.compare_digest(expected_prompt_digest, prompt_digest):
raise HostedRoomPeerError("prompt_digest does not match prompt")
return cls(
protocol_version=_positive_int(
value["protocol_version"], field="protocol_version"
),
room_id=_identifier(value["room_id"], field="room_id"),
home_install_id=_identifier(
value["home_install_id"], field="home_install_id"
),
authority_gateway_id=_identifier(
value["authority_gateway_id"], field="authority_gateway_id"
),
authority_epoch=_positive_int(
value["authority_epoch"], field="authority_epoch"
),
member_id=_identifier(value["member_id"], field="member_id"),
target_install_id=_identifier(
value["target_install_id"], field="target_install_id"
),
target_profile=_identifier(value["target_profile"], field="target_profile"),
task_id=_identifier(value["task_id"], field="task_id"),
execution_generation=_positive_int(
value["execution_generation"], field="execution_generation"
),
source_event_seq=_positive_int(
value["source_event_seq"], field="source_event_seq"
),
cancellation_scope_id=_identifier(
value["cancellation_scope_id"], field="cancellation_scope_id"
),
prompt=prompt,
prompt_digest=prompt_digest,
capability_digest=_digest(
value["capability_digest"], field="capability_digest"
),
execution_policy_digest=_digest(
value["execution_policy_digest"],
field="execution_policy_digest",
),
trace_id=_identifier(value["trace_id"], field="trace_id"),
)
@dataclass(frozen=True)
class RoomLinkProbe:
"""One gateway-verified route candidate."""
mode: LinkMode
verified: bool
encrypted: bool
latency_ms: float
def select_room_link(
probes: Iterable[RoomLinkProbe],
*,
desktop_available: bool,
) -> RoomLinkProbe | None:
"""Choose the fastest safe route without weakening encryption."""
candidates = [
probe
for probe in probes
if probe.verified
and probe.encrypted
and probe.mode != "desktop"
and math.isfinite(probe.latency_ms)
and probe.latency_ms >= 0
]
if candidates:
return min(
candidates,
key=lambda item: (_LINK_PRIORITY[item.mode], item.latency_ms),
)
if desktop_available:
return RoomLinkProbe(
mode="desktop",
verified=True,
encrypted=True,
latency_ms=0,
)
return None
_GRANT_FIELDS = {
"version",
"grant_id",
"room_id",
"home_install_id",
"authority_gateway_id",
"authority_epoch",
"member_id",
"target_install_id",
"target_profile",
"execution_policy_digest",
"permissions",
"issued_at",
"expires_at",
}
_GRANT_REFRESH_FIELDS = _GRANT_FIELDS | {"status_expires_at"}
MAX_DISPATCH_GRANT_TTL_SECONDS = 24 * 60 * 60
MAX_STATUS_GRANT_TTL_SECONDS = 30 * 24 * 60 * 60
def issue_room_grant(
secret: bytes,
*,
grant_id: str,
room_id: str,
home_install_id: str,
authority_gateway_id: str,
authority_epoch: int,
member_id: str,
target_install_id: str,
target_profile: str,
execution_policy_digest: str | None = None,
permissions: Iterable[str] = ("approve", "dispatch", "status", "stop"),
issued_at: float | None = None,
ttl_seconds: float = 3600,
status_ttl_seconds: float | None = None,
status_expires_at: float | None = None,
) -> str:
"""Issue a target-verifiable bearer grant scoped to one room member."""
if len(secret) < 32:
raise HostedRoomGrantError("room grant secret must be at least 32 bytes")
now = time.time() if issued_at is None else float(issued_at)
bounded_status_expiry = (
now + float(ttl_seconds if status_ttl_seconds is None else status_ttl_seconds)
if status_expires_at is None
else float(status_expires_at)
)
if (
not math.isfinite(now)
or ttl_seconds <= 0
or ttl_seconds > MAX_DISPATCH_GRANT_TTL_SECONDS
or not math.isfinite(bounded_status_expiry)
or bounded_status_expiry < now + float(ttl_seconds)
or bounded_status_expiry > now + MAX_STATUS_GRANT_TTL_SECONDS
):
raise HostedRoomGrantError("room grant lifetime is invalid")
allowed = tuple(sorted(set(permissions)))
if not allowed or not set(allowed) <= {
"approve",
"dispatch",
"status",
"stop",
}:
raise HostedRoomGrantError("room grant permissions are invalid")
payload = {
"version": PROTOCOL_VERSION,
"grant_id": _identifier(grant_id, field="grant_id"),
"room_id": _identifier(room_id, field="room_id"),
"home_install_id": _identifier(home_install_id, field="home_install_id"),
"authority_gateway_id": _identifier(
authority_gateway_id, field="authority_gateway_id"
),
"authority_epoch": _positive_int(
authority_epoch, field="authority_epoch"
),
"member_id": _identifier(member_id, field="member_id"),
"target_install_id": _identifier(target_install_id, field="target_install_id"),
"target_profile": _identifier(target_profile, field="target_profile"),
"execution_policy_digest": _digest(
execution_policy_digest
or execution_policy_mapping(target_profile=target_profile)["policy_digest"],
field="execution_policy_digest",
),
"permissions": list(allowed),
"issued_at": now,
"expires_at": now + float(ttl_seconds),
"status_expires_at": bounded_status_expiry,
}
encoded = _canonical_json(payload)
signature = hmac.new(secret, encoded, hashlib.sha256).digest()
token = f"{_b64encode(encoded)}.{_b64encode(signature)}"
if len(token.encode("ascii")) > MAX_TOKEN_BYTES:
raise HostedRoomGrantError("room grant is too large")
return token
def verify_room_grant(
secret: bytes,
token: str,
dispatch: HostedMemberDispatch,
*,
permission: str = "dispatch",
now: float | None = None,
) -> dict[str, Any]:
"""Verify one room grant against exact recipient dispatch coordinates."""
payload = decode_room_grant(
secret,
token,
permission=permission,
now=now,
)
if payload["version"] != dispatch.protocol_version:
raise HostedRoomGrantError("room grant protocol does not match dispatch")
expected = {
"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,
}
if any(payload.get(field) != value for field, value in expected.items()):
raise HostedRoomGrantError("room grant scope does not match dispatch")
return payload
def decode_room_grant(
secret: bytes,
token: str,
*,
permission: str,
now: float | None = None,
) -> dict[str, Any]:
"""Verify grant signature, lifetime and operation without a dispatch."""
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(".")
if not separator:
raise HostedRoomGrantError("room grant is invalid")
encoded = _b64decode(encoded_token)
supplied_signature = _b64decode(signature_token)
expected_signature = hmac.new(secret, encoded, hashlib.sha256).digest()
if not hmac.compare_digest(expected_signature, supplied_signature):
raise HostedRoomGrantError("room grant signature is invalid")
try:
payload = json.loads(encoded.decode("ascii"))
except Exception as exc:
raise HostedRoomGrantError("room grant payload is invalid") from exc
if not isinstance(payload, dict) or frozenset(payload) not in {
frozenset(_GRANT_FIELDS),
frozenset(_GRANT_REFRESH_FIELDS),
}:
raise HostedRoomGrantError("room grant fields are invalid")
checked_now = time.time() if now is None else float(now)
if not math.isfinite(checked_now):
raise HostedRoomGrantError("room grant clock is invalid")
try:
issued_at = float(payload["issued_at"])
expires_at = float(payload["expires_at"])
status_expires_at = float(payload.get("status_expires_at", expires_at))
except (TypeError, ValueError) as exc:
raise HostedRoomGrantError("room grant lifetime is invalid") from exc
if not (
math.isfinite(issued_at)
and math.isfinite(expires_at)
and math.isfinite(status_expires_at)
and issued_at < expires_at <= status_expires_at
):
raise HostedRoomGrantError("room grant lifetime is invalid")
operation_expires_at = (
status_expires_at
if permission in {"approve", "status", "stop"}
else expires_at
)
if checked_now < issued_at - 30 or checked_now >= operation_expires_at:
raise HostedRoomGrantError("room grant is expired or not active")
permissions = payload.get("permissions")
if not isinstance(permissions, list) or permission not in permissions:
raise HostedRoomGrantError("room grant does not allow this operation")
return payload
def room_grant_needs_dispatch_refresh(
token: str,
*,
now: float | None = None,
leeway_seconds: float = 5 * 60,
) -> bool:
"""Read only grant timing to schedule target-validated refresh.
This deliberately does not establish trust; the target validates the
signature and immutable scope before issuing a replacement.
"""
try:
encoded_token, separator, _signature = token.partition(".")
if not separator:
return True
payload = json.loads(_b64decode(encoded_token).decode("ascii"))
expires_at = float(payload["expires_at"])
checked_now = time.time() if now is None else float(now)
return checked_now + max(0.0, float(leeway_seconds)) >= expires_at
except Exception:
return True
+682
View File
@@ -0,0 +1,682 @@
"""Durable bounded policy projection for hosted Group Chat preparation.
The append-only room log remains the user-visible source of truth. This module
materializes only the state needed to choose and reconstruct the next active
discussion, so a busy room does not replay its complete history every poll.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping
from gateway import hosted_rooms
MAX_ACTIVE_POLICY_EVENTS = 64
MAX_THREAD_TRANSCRIPT_EVENTS = 24
_TRANSCRIPT_SCHEMA_VERSION = 1
_TERMINAL_KINDS = frozenset({
"turn.settled",
"turn.failed",
"turn.cancelled",
"turn.deferred",
})
@dataclass(frozen=True)
class PolicySnapshot:
"""Bounded active policy input at one durable room-log cursor."""
through_seq: int
stopped_through_seq: int
events: tuple[dict[str, Any], ...]
watermarks: Mapping[tuple[str, str], int]
class HostedRoomPolicyCheckpoint:
"""Incrementally index room policy without compacting visible history."""
def __init__(self, db_path: Path | str) -> None:
self.db_path = Path(db_path)
self._initialize()
def _connect(self) -> sqlite3.Connection:
from hermes_state import apply_wal_with_fallback
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path, timeout=10)
conn.row_factory = sqlite3.Row
apply_wal_with_fallback(conn, db_label="state.db (room policy checkpoint)")
return conn
def _initialize(self) -> None:
with self._connect() as conn:
conn.execute(
"""CREATE TABLE IF NOT EXISTS hosted_room_policy_cursors (
room_id TEXT PRIMARY KEY,
through_seq INTEGER NOT NULL DEFAULT 0,
stopped_through_seq INTEGER NOT NULL DEFAULT 0,
updated_at REAL NOT NULL DEFAULT 0
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS hosted_room_policy_threads (
room_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
discussion_event_id TEXT NOT NULL,
latest_user_seq INTEGER NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(room_id, thread_id)
)"""
)
conn.execute(
"""CREATE INDEX IF NOT EXISTS idx_hosted_room_policy_pending
ON hosted_room_policy_threads(
room_id, completed, latest_user_seq, thread_id
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS hosted_room_policy_events (
room_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
discussion_event_id TEXT NOT NULL,
seq INTEGER NOT NULL,
event_json TEXT NOT NULL,
PRIMARY KEY(room_id, seq)
)"""
)
conn.execute(
"""CREATE INDEX IF NOT EXISTS idx_hosted_room_policy_events_active
ON hosted_room_policy_events(
room_id, discussion_event_id, seq
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS hosted_room_policy_watermarks (
room_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
member_id TEXT NOT NULL,
seen_through_seq INTEGER NOT NULL,
PRIMARY KEY(room_id, thread_id, member_id)
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS hosted_room_policy_publications (
room_id TEXT NOT NULL,
task_id TEXT NOT NULL,
kind TEXT NOT NULL,
execution_generation INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL,
PRIMARY KEY(room_id, task_id, kind, execution_generation)
)"""
)
conn.execute(
# Store only references into the already bounded room log. This
# avoids duplicating prompt payloads outside room byte limits.
"""CREATE TABLE IF NOT EXISTS hosted_room_policy_transcript (
room_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
seq INTEGER NOT NULL,
kind TEXT NOT NULL,
settled_seq INTEGER,
PRIMARY KEY(room_id, thread_id, seq)
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS hosted_room_policy_transcript_state (
room_id TEXT PRIMARY KEY,
schema_version INTEGER NOT NULL
)"""
)
@staticmethod
def _event_json(event: Mapping[str, Any]) -> str:
return json.dumps(
dict(event),
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
def _store_active_event(
self,
conn: sqlite3.Connection,
*,
event: Mapping[str, Any],
thread_id: str,
discussion_event_id: str,
) -> None:
conn.execute(
"""INSERT OR IGNORE INTO hosted_room_policy_events(
room_id, thread_id, discussion_event_id, seq, event_json
) VALUES (?, ?, ?, ?, ?)""",
(
event["room_id"],
thread_id,
discussion_event_id,
int(event["seq"]),
self._event_json(event),
),
)
def _store_transcript_event(
self,
conn: sqlite3.Connection,
*,
event: Mapping[str, Any],
thread_id: str,
settled_seq: int | None = None,
) -> None:
conn.execute(
"""INSERT INTO hosted_room_policy_transcript(
room_id, thread_id, seq, kind, settled_seq
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(room_id, thread_id, seq) DO UPDATE SET
settled_seq=COALESCE(
excluded.settled_seq,
hosted_room_policy_transcript.settled_seq
)""",
(
event["room_id"],
thread_id,
int(event["seq"]),
str(event["kind"]),
settled_seq,
),
)
if event["kind"] in {"message.user", "message.member"}:
cutoff = conn.execute(
"""SELECT seq FROM hosted_room_policy_transcript
WHERE room_id=? AND thread_id=?
AND kind IN ('message.user', 'message.member')
ORDER BY seq DESC LIMIT 1 OFFSET ?""",
(
event["room_id"],
thread_id,
MAX_THREAD_TRANSCRIPT_EVENTS - 1,
),
).fetchone()
if cutoff is not None:
conn.execute(
"""DELETE FROM hosted_room_policy_transcript
WHERE room_id=? AND thread_id=? AND seq<?""",
(event["room_id"], thread_id, int(cutoff["seq"])),
)
@staticmethod
def _event_from_room_row(row: sqlite3.Row) -> dict[str, Any]:
return {
"room_id": str(row["room_id"]),
"seq": int(row["seq"]),
"event_id": str(row["event_id"]),
"kind": str(row["kind"]),
"actor": json.loads(row["actor_json"]),
"authority_epoch": row["authority_epoch"],
"payload": json.loads(row["payload_json"]),
"created_at": float(row["created_at"]),
"idempotent": False,
}
def _backfill_transcript(
self,
conn: sqlite3.Connection,
*,
room_id: str,
through_seq: int,
) -> None:
"""Migrate bounded committed thread history from the durable room log."""
if through_seq <= 0:
return
settled_seq_by_message: dict[str, int] = {}
for row in conn.execute(
"""SELECT seq, payload_json FROM hosted_room_events
WHERE room_id=? AND seq<=? AND kind='turn.settled'
ORDER BY seq""",
(room_id, through_seq),
):
message_event_id = str(
json.loads(row["payload_json"]).get("message_event_id") or ""
)
if message_event_id:
settled_seq_by_message[message_event_id] = int(row["seq"])
rows = conn.execute(
"""SELECT room_id, seq, event_id, kind, actor_json,
authority_epoch, payload_json, created_at
FROM hosted_room_events
WHERE room_id=? AND seq<=?
AND kind IN ('message.user', 'message.member')
ORDER BY seq""",
(room_id, through_seq),
)
for row in rows:
if (
row["kind"] == "message.member"
and row["event_id"] not in settled_seq_by_message
):
continue
event = self._event_from_room_row(row)
thread_id = str(event["payload"].get("thread_id") or "")
if thread_id:
self._store_transcript_event(
conn,
event=event,
thread_id=thread_id,
settled_seq=settled_seq_by_message.get(str(row["event_id"])),
)
def _transcript_events(
self,
conn: sqlite3.Connection,
*,
room_id: str,
thread_id: str,
) -> list[dict[str, Any]]:
rows = conn.execute(
"""WITH transcript_events(seq) AS (
SELECT seq FROM hosted_room_policy_transcript
WHERE room_id=? AND thread_id=?
UNION ALL
SELECT settled_seq FROM hosted_room_policy_transcript
WHERE room_id=? AND thread_id=? AND settled_seq IS NOT NULL
)
SELECT events.room_id, events.seq, events.event_id,
events.kind, events.actor_json,
events.authority_epoch, events.payload_json,
events.created_at
FROM transcript_events
JOIN hosted_room_events AS events
ON events.room_id=? AND events.seq=transcript_events.seq
ORDER BY events.seq""",
(room_id, thread_id, room_id, thread_id, room_id),
).fetchall()
return [self._event_from_room_row(row) for row in rows]
def _apply_event(self, conn: sqlite3.Connection, event: Mapping[str, Any]) -> None:
room_id = str(event["room_id"])
seq = int(event["seq"])
kind = str(event.get("kind") or "")
payload = event.get("payload")
payload = payload if isinstance(payload, Mapping) else {}
if kind == "message.user":
thread_id = str(payload.get("thread_id") or "")
event_id = str(event.get("event_id") or "")
if not thread_id or not event_id:
return
conn.execute(
"""INSERT INTO hosted_room_policy_threads(
room_id, thread_id, discussion_event_id,
latest_user_seq, completed
) VALUES (?, ?, ?, ?, 0)
ON CONFLICT(room_id, thread_id) DO UPDATE SET
discussion_event_id=excluded.discussion_event_id,
latest_user_seq=excluded.latest_user_seq,
completed=0""",
(room_id, thread_id, event_id, seq),
)
self._store_active_event(
conn,
event=event,
thread_id=thread_id,
discussion_event_id=event_id,
)
self._store_transcript_event(
conn,
event=event,
thread_id=thread_id,
)
return
if kind in {"message.member", *_TERMINAL_KINDS}:
thread_id = str(payload.get("thread_id") or "")
discussion_event_id = str(payload.get("discussion_event_id") or "")
source = conn.execute(
"""SELECT 1 FROM hosted_room_policy_events
WHERE room_id=? AND discussion_event_id=? LIMIT 1""",
(room_id, discussion_event_id),
).fetchone()
if source is None:
return
self._store_active_event(
conn,
event=event,
thread_id=thread_id,
discussion_event_id=discussion_event_id,
)
if kind in _TERMINAL_KINDS:
task_id = str(payload.get("task_id") or "")
execution_generation = (
int(payload.get("execution_generation") or 0)
if kind == "turn.deferred"
else 0
)
if task_id:
conn.execute(
"""INSERT OR IGNORE INTO hosted_room_policy_publications(
room_id, task_id, kind, execution_generation, seq
) VALUES (?, ?, ?, ?, ?)""",
(
room_id,
task_id,
kind,
execution_generation,
seq,
),
)
member_id = str(payload.get("member_id") or "")
seen_through_seq = int(payload.get("seen_through_seq") or 0)
if kind == "turn.settled" and payload.get("message_event_id"):
messages = conn.execute(
"""SELECT seq, event_json FROM hosted_room_policy_events
WHERE room_id=? AND discussion_event_id=?""",
(room_id, discussion_event_id),
).fetchall()
decoded_messages = [
json.loads(message["event_json"])
for message in messages
]
committed = next(
(
message
for message in decoded_messages
if message.get("event_id")
== payload["message_event_id"]
),
None,
)
if committed is not None:
seen_through_seq = max(
seen_through_seq,
int(committed["seq"]),
)
self._store_transcript_event(
conn,
event=committed,
thread_id=thread_id,
settled_seq=int(event["seq"]),
)
if member_id and seen_through_seq > 0:
conn.execute(
"""INSERT INTO hosted_room_policy_watermarks(
room_id, thread_id, member_id, seen_through_seq
) VALUES (?, ?, ?, ?)
ON CONFLICT(room_id, thread_id, member_id) DO UPDATE SET
seen_through_seq=MAX(
hosted_room_policy_watermarks.seen_through_seq,
excluded.seen_through_seq
)""",
(room_id, thread_id, member_id, seen_through_seq),
)
return
if kind == "room.activity":
thread_id = str(payload.get("thread_id") or "")
discussion_event_id = str(payload.get("discussion_event_id") or "")
conn.execute(
"""DELETE FROM hosted_room_policy_events
WHERE room_id=? AND discussion_event_id=?""",
(room_id, discussion_event_id),
)
conn.execute(
"""DELETE FROM hosted_room_policy_threads
WHERE room_id=? AND thread_id=?""",
(room_id, thread_id),
)
return
if kind == "room.stop_requested":
conn.execute(
"""UPDATE hosted_room_policy_cursors
SET stopped_through_seq=MAX(stopped_through_seq, ?)
WHERE room_id=?""",
(seq, room_id),
)
def sync(self, *, room_id: str, latest_seq: int) -> int:
"""Materialize each unseen event exactly once by durable cursor."""
with self._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
if conn.execute(
"SELECT 1 FROM hosted_rooms WHERE room_id=?",
(room_id,),
).fetchone() is None:
raise hosted_rooms.RoomNotFoundError("hosted room not found")
conn.execute(
"""INSERT OR IGNORE INTO hosted_room_policy_cursors(
room_id, through_seq, stopped_through_seq, updated_at
) VALUES (?, 0, 0, 0)""",
(room_id,),
)
row = conn.execute(
"SELECT through_seq FROM hosted_room_policy_cursors WHERE room_id=?",
(room_id,),
).fetchone()
cursor = int(row["through_seq"])
transcript_state = conn.execute(
"""SELECT schema_version
FROM hosted_room_policy_transcript_state WHERE room_id=?""",
(room_id,),
).fetchone()
if (
transcript_state is None
or int(transcript_state["schema_version"])
< _TRANSCRIPT_SCHEMA_VERSION
):
self._backfill_transcript(
conn,
room_id=room_id,
through_seq=cursor,
)
conn.execute(
"""INSERT INTO hosted_room_policy_transcript_state(
room_id, schema_version
) VALUES (?, ?)
ON CONFLICT(room_id) DO UPDATE SET
schema_version=excluded.schema_version""",
(room_id, _TRANSCRIPT_SCHEMA_VERSION),
)
if cursor > latest_seq:
raise RuntimeError("room policy cursor is ahead of the durable log")
while cursor < latest_seq:
page = hosted_rooms.read_events(
self.db_path,
room_id=room_id,
since_seq=cursor,
limit=hosted_rooms.MAX_LOG_LIMIT,
)
rows = [
event for event in page.get("events", []) if isinstance(event, Mapping)
]
next_cursor = int(page.get("cursor") or cursor)
if not rows or next_cursor <= cursor:
raise RuntimeError("hosted room policy cursor did not advance")
with self._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
if conn.execute(
"SELECT 1 FROM hosted_rooms WHERE room_id=?",
(room_id,),
).fetchone() is None:
raise hosted_rooms.RoomNotFoundError("hosted room not found")
for event in rows:
self._apply_event(conn, event)
updated = conn.execute(
"""UPDATE hosted_room_policy_cursors
SET through_seq=?, updated_at=? WHERE room_id=?""",
(
next_cursor,
float(rows[-1].get("created_at") or 0),
room_id,
),
)
if updated.rowcount != 1:
raise RuntimeError("room policy cursor disappeared during replay")
cursor = next_cursor
return cursor
def snapshot(self, *, room_id: str, latest_seq: int) -> PolicySnapshot:
"""Return only the oldest active discussion and its watermark set."""
through_seq = self.sync(room_id=room_id, latest_seq=latest_seq)
with self._connect() as conn:
cursor = conn.execute(
"""SELECT stopped_through_seq FROM hosted_room_policy_cursors
WHERE room_id=?""",
(room_id,),
).fetchone()
stopped_through_seq = int(cursor["stopped_through_seq"])
thread = conn.execute(
"""SELECT thread_id, discussion_event_id
FROM hosted_room_policy_threads
WHERE room_id=? AND completed=0 AND latest_user_seq>?
ORDER BY latest_user_seq, thread_id LIMIT 1""",
(room_id, stopped_through_seq),
).fetchone()
if thread is None:
return PolicySnapshot(
through_seq=through_seq,
stopped_through_seq=stopped_through_seq,
events=(),
watermarks={},
)
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(thread["discussion_event_id"]),
MAX_ACTIVE_POLICY_EVENTS + 1,
),
).fetchall()
if len(active_rows) > MAX_ACTIVE_POLICY_EVENTS:
raise RuntimeError("active room policy projection exceeded its bound")
transcript_events = self._transcript_events(
conn,
room_id=room_id,
thread_id=str(thread["thread_id"]),
)
watermark_rows = conn.execute(
"""SELECT member_id, seen_through_seq
FROM hosted_room_policy_watermarks
WHERE room_id=? AND thread_id=?""",
(room_id, str(thread["thread_id"])),
).fetchall()
events_by_seq = {
int(event["seq"]): event
for event in (
*transcript_events,
*(json.loads(row["event_json"]) for row in active_rows),
)
}
return PolicySnapshot(
through_seq=through_seq,
stopped_through_seq=stopped_through_seq,
events=tuple(events_by_seq[seq] for seq in sorted(events_by_seq)),
watermarks={
(str(thread["thread_id"]), str(row["member_id"])): int(
row["seen_through_seq"]
)
for row in watermark_rows
},
)
def publication_exists(
self,
*,
room_id: str,
task_id: str,
status: str,
execution_generation: int,
) -> bool:
"""Return whether one exact driver outcome is already in the room log."""
kind = f"turn.{status}"
generation = execution_generation if status == "deferred" else 0
with self._connect() as conn:
if status == "deferred":
row = conn.execute(
"""SELECT 1 FROM hosted_room_policy_publications
WHERE room_id=? AND task_id=? AND kind=?
AND execution_generation=?""",
(room_id, task_id, kind, generation),
).fetchone()
else:
row = conn.execute(
"""SELECT 1 FROM hosted_room_policy_publications
WHERE room_id=? AND task_id=? AND kind IN (
'turn.settled', 'turn.failed', 'turn.cancelled'
)""",
(room_id, task_id),
).fetchone()
return row is not None
def events_for_task(
self,
*,
room_id: str,
source_event_seq: int,
) -> list[dict[str, Any]]:
"""Load one bounded discussion projection for terminal reconstruction."""
with self._connect() as conn:
source = conn.execute(
"""SELECT discussion_event_id, thread_id
FROM hosted_room_policy_events
WHERE room_id=? AND seq=?""",
(room_id, source_event_seq),
).fetchone()
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,
thread_id=str(source["thread_id"]),
)
if len(active_rows) > MAX_ACTIVE_POLICY_EVENTS:
raise RuntimeError("task policy projection exceeded its bound")
events_by_seq = {
int(event["seq"]): event
for event in (
*transcript_events,
*(json.loads(row["event_json"]) for row in active_rows),
)
}
return [events_by_seq[seq] for seq in sorted(events_by_seq)]
def compact_completed(self, *, room_id: str) -> None:
"""Drop any completed projections left by an interrupted sync."""
with self._connect() as conn:
completed = conn.execute(
"""SELECT discussion_event_id FROM hosted_room_policy_threads
WHERE room_id=? AND completed=1""",
(room_id,),
).fetchall()
for row in completed:
conn.execute(
"""DELETE FROM hosted_room_policy_events
WHERE room_id=? AND discussion_event_id=?""",
(room_id, str(row["discussion_event_id"])),
)
conn.execute(
"""DELETE FROM hosted_room_policy_threads
WHERE room_id=? AND completed=1""",
(room_id,),
)
+570
View File
@@ -0,0 +1,570 @@
"""Replica store and takeover primitives 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.
"""
from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from typing import Any
from gateway.hosted_rooms import (
MAX_ACTOR_ID_CHARS,
MAX_EVENT_JSON_BYTES,
MAX_ROOM_ID_CHARS,
HostedRoomError,
RoomConflictError,
_canonical_json,
_connect,
_transaction,
_validate_identifier,
_validate_members,
_validate_room_name,
local_authority_gateway_id,
)
MAX_REPLICA_ROOMS = 256
MAX_REPLICA_EVENT_BYTES = 256 * 1024 * 1024
class ReplicaError(HostedRoomError):
"""Base class for invalid or conflicting replica operations."""
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."""
def _initialize_replica_schema(conn: sqlite3.Connection) -> None:
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
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS hosted_room_replica_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,
payload_json TEXT NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (room_id, seq)
)"""
)
def _replica_transaction(db_path: Path | str):
return _transaction(db_path, immediate=True)
def _ensure_schema(db_path: Path | str) -> None:
conn = _connect(db_path)
try:
with conn:
_initialize_replica_schema(conn)
finally:
conn.close()
def _event_bytes(event: dict[str, Any]) -> int:
return (
len(str(event["event_id"]).encode("utf-8"))
+ len(str(event["kind"]).encode("utf-8"))
+ len(
json.dumps(
event["actor"], ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
)
+ len(
json.dumps(
event["payload"], ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
)
)
def _validate_page(page: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]:
if not isinstance(page, dict):
raise ReplicaError("page must be an object")
events = page.get("events")
authority = page.get("authority")
if not isinstance(events, list):
raise ReplicaError("page.events must be a list")
if not isinstance(authority, dict):
raise ReplicaError("page.authority is required for replication")
gateway_id = _validate_identifier(
authority.get("gateway_id"),
label="page.authority.gateway_id",
max_chars=MAX_ACTOR_ID_CHARS,
)
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")
previous_seq: int | None = None
for event in events:
if not isinstance(event, dict):
raise ReplicaError("page events must be objects")
seq = event.get("seq")
if isinstance(seq, bool) or not isinstance(seq, int) or seq < 1:
raise ReplicaError("event.seq must be a positive integer")
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}
def ingest_page(
db_path: Path | str,
*,
room_id: Any,
room_name: Any,
members: Any,
page: Any,
now: float | None = None,
) -> dict[str, Any]:
"""Persist one replay page for ``room_id``; idempotent, gap- and
epoch-regression-safe.
``page`` is the verbatim result of the authority's ``groups.log`` call
(``read_events()``), whose ``authority`` stamp proves lineage.
"""
room_id = _validate_identifier(
room_id, label="room_id", max_chars=MAX_ROOM_ID_CHARS
)
room_name = _validate_room_name(room_name)
_, members_json = _validate_members(members)
events, authority = _validate_page(page)
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)
row = conn.execute(
"""SELECT authority_gateway_id, authority_epoch, last_seq,
latest_seq, event_bytes
FROM hosted_room_replicas WHERE room_id=?""",
(room_id,),
).fetchone()
if row is None:
count = conn.execute(
"SELECT COUNT(*) FROM hosted_room_replicas"
).fetchone()[0]
if int(count) >= MAX_REPLICA_ROOMS:
raise ReplicaError("replica room capacity exhausted")
stored_epoch = 0
last_seq = 0
stored_bytes = 0
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"
)
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
)
payload_json = _canonical_json(
event["payload"], label="payload", max_bytes=MAX_EVENT_JSON_BYTES
)
epoch_value = event.get("authority_epoch")
conn.execute(
"""INSERT INTO hosted_room_replica_events
(room_id, seq, event_id, kind, actor_json, authority_epoch,
payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
room_id,
int(event["seq"]),
event["event_id"],
event["kind"],
actor_json,
epoch_value,
payload_json,
float(event.get("created_at") or now),
),
)
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
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
room_id,
room_name,
members_json,
authority["gateway_id"],
authority["epoch"],
new_last,
max(latest_seq, new_last),
added_bytes,
now,
now,
),
)
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=?
WHERE room_id=?""",
(
room_name,
members_json,
authority["gateway_id"],
authority["epoch"],
new_last,
max(latest_seq, new_last),
added_bytes,
now,
room_id,
),
)
return {
"room_id": room_id,
"stored_seq": new_last,
"ingested": len(new_events),
"authority": authority,
"caught_up": new_last >= max(latest_seq, new_last),
}
def replica_state(db_path: Path | str, *, room_id: Any) -> dict[str, Any]:
"""Return the stored replica's coverage and authority lineage."""
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
FROM hosted_room_replicas WHERE room_id=?""",
(room_id,),
).fetchone()
if row is None:
raise ReplicaError("replica not found")
return {
"room_id": row["room_id"],
"name": row["name"],
"members": json.loads(row["members_json"]),
"authority": {
"gateway_id": row["authority_gateway_id"],
"epoch": int(row["authority_epoch"]),
},
"last_seq": int(row["last_seq"]),
"latest_seq": int(row["latest_seq"]),
"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,
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+384
View File
@@ -0,0 +1,384 @@
"""Gateway lifecycle ledger — durable termination-reason evidence (NS-608).
The gateway already has *graceful* shutdown forensics
(:mod:`gateway.shutdown_forensics` — who sent the SIGTERM) and an exit-path
diagnostic log (``gateway-exit-diag.log`` — every way ``asyncio.run`` can
return). What it does NOT have is any record of an **unclean death**: a
SIGKILL, a kernel OOM kill, or the whole VM dying takes the process out
before any handler runs, so the next boot has no idea the previous life
ended violently — support tickets like NS-608 then require manually
cross-correlating four log files and two external APIs to answer "what
killed the gateway?".
This module closes that gap with a tiny state machine persisted to
``<HERMES_HOME>/state/gateway.lifecycle.json``:
* On startup, :func:`record_startup` reads the sentinel left by the
previous life. ``phase == "running"`` means that life never reached any
exit path → it died uncleanly. The finding — including the last
heartbeat's memory sample, which is the closest thing to a pre-death
telemetry snapshot — is appended to ``gateway-exit-diag.log`` as a
``gateway.previous_unclean_exit`` record and logged at WARNING. The
sentinel is then rewritten as ``phase=running`` for the new life.
* On every clean exit path, :func:`mark_exited` rewrites the sentinel as
``phase=exited`` with the exit code and a reason string. Wired into
``_exit_after_graceful_shutdown`` (the single funnel for all graceful
exits, #53107) and the two watchdog ``os._exit`` sites in
:mod:`gateway.shutdown_watchdog`.
:func:`sample_memory` provides the cheap (<1ms, pure /proc reads) memory
snapshot that :func:`gateway.shutdown_watchdog.write_loop_heartbeat`
embeds in the 30s heartbeat — giving every unclean-death report a
"memory available N seconds before death" data point so OOM crash cycles
are classifiable from the volume alone (no Prometheus retention races).
Everything here is best-effort: a forensics failure must never affect the
gateway lifecycle it is observing.
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_LIFECYCLE_RELATIVE = ("state", "gateway.lifecycle.json")
_EXIT_DIAG_RELATIVE = ("logs", "gateway-exit-diag.log")
_STATE_DB_RELATIVE = ("state.db",)
# Heuristic OOM-suspicion thresholds applied to the last heartbeat's memory
# sample. Deliberately conservative: this only annotates the report with a
# hint; classification stays with the human reading the evidence.
_LOW_MEM_AVAILABLE_KIB = 64 * 1024 # < 64 MiB available
_LOW_MEM_AVAILABLE_FRACTION = 0.05 # < 5% of MemTotal available
def _process_hermes_home() -> Path:
"""HERMES_HOME for process-level identity files (ignore task overrides)."""
val = os.environ.get("HERMES_HOME", "").strip()
if val:
return Path(val)
from hermes_constants import get_hermes_home
return get_hermes_home()
def get_lifecycle_sentinel_path(home: Optional[Path] = None) -> Path:
"""Return ``<HERMES_HOME>/state/gateway.lifecycle.json``."""
base = home if home is not None else _process_hermes_home()
return base.joinpath(*_LIFECYCLE_RELATIVE)
def sample_memory() -> Dict[str, Any]:
"""Cheap memory snapshot: own RSS + system availability + swap.
Pure ``/proc`` reads, Linux-only (returns ``{}`` elsewhere), never
raises. Values in KiB to match the kernel's units.
"""
sample: Dict[str, Any] = {}
try:
with open("/proc/self/status", encoding="utf-8") as fh:
for line in fh:
if line.startswith("VmRSS:"):
sample["rss_kib"] = int(line.split()[1])
break
except (OSError, ValueError, IndexError):
pass
try:
meminfo: Dict[str, int] = {}
wanted = {"MemTotal", "MemAvailable", "SwapTotal", "SwapFree"}
with open("/proc/meminfo", encoding="utf-8") as fh:
for line in fh:
key = line.split(":", 1)[0]
if key in wanted:
meminfo[key] = int(line.split()[1])
if len(meminfo) == len(wanted):
break
if "MemTotal" in meminfo:
sample["mem_total_kib"] = meminfo["MemTotal"]
if "MemAvailable" in meminfo:
sample["mem_available_kib"] = meminfo["MemAvailable"]
if "SwapTotal" in meminfo and "SwapFree" in meminfo:
sample["swap_used_kib"] = meminfo["SwapTotal"] - meminfo["SwapFree"]
except (OSError, ValueError, IndexError):
pass
return sample
def _read_json(path: Path) -> Optional[Dict[str, Any]]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _write_sentinel(payload: Dict[str, Any], home: Optional[Path]) -> None:
path = get_lifecycle_sentinel_path(home)
try:
from utils import atomic_json_write
path.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(path, payload, indent=None)
except Exception:
logger.debug("Failed to write lifecycle sentinel", exc_info=True)
def _append_exit_diag(record: Dict[str, Any], home: Optional[Path]) -> None:
"""Append a JSON line to gateway-exit-diag.log (same format as the CLI's
``_exit_diag`` records so existing tooling greps both)."""
base = home if home is not None else _process_hermes_home()
path = base.joinpath(*_EXIT_DIAG_RELATIVE)
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, default=str) + "\n")
except OSError:
logger.debug("Failed to append unclean-exit record", exc_info=True)
def _pid_alive_with_start_time(pid: Any, start_time: Any) -> bool:
"""True when ``pid`` is a live process matching ``start_time`` (±2s).
Guards the takeover race: during ``--replace`` the old gateway can still
be mid-teardown when the new one boots — a live matching owner is a
planned handover, not an unclean death.
"""
try:
pid_int = int(pid)
except (TypeError, ValueError):
return False
if pid_int <= 0:
return False
try:
# NOT os.kill(pid, 0): on Windows that sends CTRL_C_EVENT to the
# target's console group (bpo-14484). _pid_exists is the repo's
# canonical no-kill cross-platform probe (psutil-backed).
from gateway.status import _pid_exists
if not _pid_exists(pid_int):
return False
except Exception:
return False
if start_time is None:
return True # alive; can't disambiguate PID reuse — err on "alive"
try:
from gateway.status import get_process_start_time
actual = get_process_start_time(pid_int)
if actual is None:
return True
return abs(float(actual) - float(start_time)) <= 2.0
except Exception:
return True
def detect_unclean_exit(home: Optional[Path] = None) -> Optional[Dict[str, Any]]:
"""Inspect the previous life's sentinel; return an evidence dict when it
died uncleanly, else ``None``. Read-only — does not rewrite the sentinel.
"""
sentinel = _read_json(get_lifecycle_sentinel_path(home))
if not sentinel or sentinel.get("phase") != "running":
return None
if _pid_alive_with_start_time(sentinel.get("pid"), sentinel.get("start_time")):
return None # live owner — planned takeover in flight, not a death
evidence: Dict[str, Any] = {
"prior_pid": sentinel.get("pid"),
"prior_started_at": sentinel.get("started_at"),
"prior_start_time": sentinel.get("start_time"),
}
# Enrich with the last heartbeat: when did the loop last prove liveness,
# and what did memory look like at that moment?
try:
from gateway.shutdown_watchdog import get_loop_heartbeat_path
hb = _read_json(get_loop_heartbeat_path(home))
except Exception:
hb = None
if hb:
evidence["last_heartbeat_at"] = hb.get("updated_at")
mem = hb.get("mem")
if isinstance(mem, dict):
evidence["last_heartbeat_mem"] = mem
total = mem.get("mem_total_kib")
avail = mem.get("mem_available_kib")
if isinstance(avail, int) and (
avail < _LOW_MEM_AVAILABLE_KIB
or (
isinstance(total, int)
and total > 0
and avail / total < _LOW_MEM_AVAILABLE_FRACTION
)
):
evidence["suspected_oom"] = True
return evidence
def check_state_db_integrity(home: Optional[Path] = None) -> str:
"""Return ``"ok"``, ``"absent"``, or the first ``quick_check`` complaint.
Called only after an unclean death, because that is when the store may
have been torn: a SIGKILL landing on a gateway mid-WAL-checkpoint can
leave half-written b-tree pages behind (see
``_enforce_macos_synchronous_full`` in :mod:`hermes_state` — macOS
``fsync`` guarantees neither data-on-platter nor write ordering).
``quick_check(1)`` stops at the first problem, so this costs ~2s on a
healthy 500MB store and less on a damaged one — cheap enough for a path
that runs at most once per unclean boot, far too expensive for every
boot. Corruption used to sit undetected for days (2026-08-26 → 08-30)
because nothing ever looked.
The connection is opened normally, not read-only: a WAL store needs its
-shm sidecar for a read-only open. The PRAGMA itself writes nothing.
Never raises — this is forensics, not lifecycle.
"""
base = home if home is not None else _process_hermes_home()
path = base.joinpath(*_STATE_DB_RELATIVE)
if not path.exists():
return "absent"
try:
# A WAL store cannot be opened read-only without its -shm sidecar, so
# open normally; quick_check itself writes nothing.
conn = sqlite3.connect(str(path))
try:
row = conn.execute("PRAGMA quick_check(1)").fetchone()
finally:
conn.close()
except Exception as exc: # sqlite3.Error, OSError, anything
return f"check-failed: {exc}"
if not row or row[0] is None:
return "check-failed: no result"
return str(row[0])
def record_startup(home: Optional[Path] = None) -> Optional[Dict[str, Any]]:
"""Boot-time entry point: report any unclean previous exit, then claim
the sentinel for the current life.
Returns the unclean-exit evidence dict (also persisted to
``gateway-exit-diag.log`` and logged at WARNING) or ``None``. Never
raises.
"""
evidence: Optional[Dict[str, Any]] = None
try:
evidence = detect_unclean_exit(home)
if evidence is not None:
# The death may have torn the store. This is the only moment
# we know to look, and looking is what turns a 3.5-day silent
# corruption into a startup warning.
verdict = check_state_db_integrity(home=home)
evidence["state_db_integrity"] = verdict
if verdict not in ("ok", "absent"):
logger.error(
"state.db FAILED integrity check after an unclean gateway "
"exit: %s — sessions may read as missing until it is "
"repaired. Run `hermes doctor`.",
verdict,
)
record = {
"ts": datetime.now(timezone.utc).isoformat(),
"tag": "gateway.previous_unclean_exit",
"pid": os.getpid(),
**evidence,
}
_append_exit_diag(record, home)
logger.warning(
"Previous gateway life (pid=%s, started_at=%s) exited UNCLEANLY "
"(no exit path ran — SIGKILL / OOM / VM death). "
"last_heartbeat_at=%s last_mem=%s suspected_oom=%s",
evidence.get("prior_pid"),
evidence.get("prior_started_at"),
evidence.get("last_heartbeat_at"),
evidence.get("last_heartbeat_mem"),
evidence.get("suspected_oom", False),
)
except Exception:
logger.debug("Unclean-exit detection failed", exc_info=True)
try:
claim: Dict[str, Any] = {
"phase": "running",
"pid": os.getpid(),
"start_time": time.time(),
"started_at": datetime.now(timezone.utc).isoformat(),
}
# Carry the verdict on the PREVIOUS life forward on the new
# sentinel: it is the only place the finding survives in
# machine-readable form (the exit-diag log is append-only prose
# for humans), and /api/status reads it to tell the user "your
# agent restarted after (suspected) running out of memory"
# (NS-656). Scoped to this life only — the next clean exit or
# boot rewrites the sentinel and the flags age out with it.
if evidence is not None:
claim["prior_unclean_exit"] = True
if evidence.get("suspected_oom"):
claim["prior_suspected_oom"] = True
_write_sentinel(claim, home)
except Exception:
logger.debug("Failed to claim lifecycle sentinel", exc_info=True)
return evidence
def mark_exited(
exit_code: Optional[int] = None,
reason: str = "graceful_shutdown",
home: Optional[Path] = None,
) -> None:
"""Mark the current life as cleanly exited. Idempotent, never raises.
Only rewrites the sentinel when it is provably owned by this process —
during a ``--replace`` takeover the replacement claims the sentinel
before the old process finishes teardown, and the old life must not
clobber the new owner's ``running`` phase on its way out. A sentinel
with ``pid=None`` (or a malformed pid) has *unknown* ownership and is
likewise left alone: we must not overwrite evidence we cannot prove is
ours with a ``clean exit`` claim.
"""
try:
sentinel = _read_json(get_lifecycle_sentinel_path(home))
if sentinel is not None and sentinel.get("pid") != os.getpid():
return
_write_sentinel(
{
"phase": "exited",
"pid": os.getpid(),
"exit_code": exit_code,
"exit_reason": reason,
"exited_at": datetime.now(timezone.utc).isoformat(),
},
home,
)
except Exception:
logger.debug("Failed to mark lifecycle sentinel exited", exc_info=True)
def read_prior_exit_label(profile_home: Path) -> str:
"""Container-boot helper: one-word summary of how the profile's last
gateway life ended. ``clean`` / ``unclean`` / ``unknown`` (no sentinel
or never ran). Read-only and exception-free — used by
``hermes_cli.container_boot`` to annotate ``container-boot.log``.
"""
try:
sentinel = _read_json(get_lifecycle_sentinel_path(profile_home))
if not sentinel:
return "unknown"
phase = sentinel.get("phase")
if phase == "exited":
return "clean"
if phase == "running":
# At container boot the old PID namespace is gone — any
# "running" sentinel is from a life that never exited cleanly.
return "unclean"
except Exception:
pass
return "unknown"
+88
View File
@@ -0,0 +1,88 @@
"""Shared config→env bridge for media-delivery policy.
``validate_media_delivery_path`` (gateway/platforms/base.py) reads its policy
from environment variables:
- ``HERMES_MEDIA_DELIVERY_STRICT`` <- gateway.strict
- ``HERMES_MEDIA_ALLOW_DIRS`` <- gateway.media_delivery_allow_dirs
- ``HERMES_MEDIA_TRUST_RECENT_FILES`` <- gateway.trust_recent_files
Historically the config.yaml -> env translation ran ONLY in gateway startup
(gateway/run.py), so any process that delivers media without booting the
gateway — a manual ``hermes cron run`` in the CLI, ``hermes send``, a
standalone cron tick — filtered MEDIA paths under DIFFERENT policy than the
gateway's scheduled deliveries. In strict/allowlisted enterprise deployments
that divergence silently dropped attachments from manual cron runs while
scheduled runs delivered them (text is unaffected — only media goes through
path validation).
``apply_media_policy_env()`` is that same translation as a shared, idempotent
helper. Gateway startup calls it, and every standalone delivery entrypoint
calls it immediately before filtering media paths.
Precedence: an explicitly-set environment variable WINS over config.yaml.
This preserves both the operator contract (env overrides are how deployments
pin behavior) and gateway/run.py's historical shape (it only wrote the env
var when the config key was present; we additionally refuse to overwrite a
pre-existing env value so a shell-exported override survives).
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_STRICT_ENV = "HERMES_MEDIA_DELIVERY_STRICT"
_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES"
def _load_gateway_cfg(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if config is None:
try:
from hermes_cli.config import load_config
config = load_config() or {}
except Exception:
return {}
gateway_cfg = config.get("gateway", {})
return gateway_cfg if isinstance(gateway_cfg, dict) else {}
def apply_media_policy_env(config: Optional[Dict[str, Any]] = None) -> None:
"""Bridge gateway media-policy settings from config.yaml into the env.
Idempotent and env-wins: a variable already present in the environment is
never overwritten, so gateway startup (which runs this same helper) and
operator shell exports keep precedence. Never raises — a policy-bridge
failure must not break delivery; the validator falls back to its
defaults exactly as before.
"""
try:
gateway_cfg = _load_gateway_cfg(config)
if not gateway_cfg:
return
strict = gateway_cfg.get("strict")
if strict is not None and not os.environ.get(_STRICT_ENV):
os.environ[_STRICT_ENV] = "1" if strict else "0"
allow_dirs = gateway_cfg.get("media_delivery_allow_dirs")
if allow_dirs and not os.environ.get(_ALLOW_DIRS_ENV):
if isinstance(allow_dirs, str):
allow_dirs_str = allow_dirs
elif isinstance(allow_dirs, (list, tuple)):
allow_dirs_str = os.pathsep.join(str(p) for p in allow_dirs if p)
else:
allow_dirs_str = ""
if allow_dirs_str:
os.environ[_ALLOW_DIRS_ENV] = allow_dirs_str
trust_recent = gateway_cfg.get("trust_recent_files")
if trust_recent is not None and not os.environ.get(_TRUST_RECENT_ENV):
os.environ[_TRUST_RECENT_ENV] = "1" if trust_recent else "0"
except Exception: # noqa: BLE001 - policy bridge must never break delivery
logger.debug("apply_media_policy_env failed", exc_info=True)
+213
View File
@@ -0,0 +1,213 @@
"""Repair model-mangled ``computer_use`` screenshot paths in final responses.
``computer_use`` persists a bounded screenshot into the Hermes image cache and
tells the model its absolute path. Some models rewrite a Windows path into a
POSIX-looking one (``C:\\Users\\Alice\\...`` -> ``/Users/Alice/...``) when
emitting an explicit ``MEDIA:`` directive, so delivery-path validation rejects
the nonexistent path and the attachment is dropped.
The repair is deliberately narrow: it only rewrites paths inside a response
that *already* carries an explicit ``MEDIA:`` directive, and only when the
directive's generated ``computer_use_<uuid>`` basename exactly matches a
canonical screenshot path returned by ``computer_use`` in the current turn.
It never auto-attaches captures, and normal media path validation still runs
after the repair.
This lives in its own module (mirroring ``gateway/media_policy.py``) so every
delivery surface — the messaging gateway's main turn path, gateway background
tasks, and cron job delivery — shares one implementation.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, Iterator, List
logger = logging.getLogger(__name__)
# Absolute-path prefix accepted for canonical capture paths: Windows drive
# letter, POSIX root, or UNC share. Kept as a pattern string so the summary
# regex below and the compiled prefix check stay in sync.
_ABS_PATH_PREFIX_PATTERN = r"(?:[A-Za-z]:[/\\]|/|\\\\)"
_ABS_PATH_PREFIX_RE = re.compile(r"^" + _ABS_PATH_PREFIX_PATTERN)
_COMPUTER_USE_CAPTURE_BASENAME_RE = re.compile(
r"^computer_use_[0-9a-f]{32}\.(?:png|jpe?g)$",
re.IGNORECASE,
)
_COMPUTER_USE_CAPTURE_SUMMARY_RE = re.compile(
r"\(shareable screenshot saved to "
r"(?P<path>" + _ABS_PATH_PREFIX_PATTERN + r"[^\r\n]*?"
r"computer_use_[0-9a-f]{32}\.(?:png|jpe?g))\)",
re.IGNORECASE,
)
def tool_name_by_call_id(messages: List[Dict[str, Any]]) -> Dict[str, str]:
"""Map assistant tool-call ids to tool names for the given messages."""
mapping: Dict[str, str] = {}
for msg in messages:
if msg.get("role") != "assistant":
continue
for call in msg.get("tool_calls") or []:
call_id = call.get("id") or call.get("call_id")
fn = call.get("function") or {}
name = str(fn.get("name") or call.get("name") or "")
if call_id and name:
mapping[str(call_id)] = name
return mapping
def _computer_use_capture_basename(path: Any) -> str:
"""Return a canonical capture basename for either path separator style."""
value = str(path or "").strip().strip("`\"'")
basename = re.split(r"[/\\]", value)[-1]
if _COMPUTER_USE_CAPTURE_BASENAME_RE.fullmatch(basename):
return basename.lower()
return ""
def _iter_computer_use_capture_paths(content: Any) -> Iterator[str]:
"""Yield persisted screenshot paths from computer_use result content.
The tool can return JSON, a multimodal content list, or a text fallback.
The latter two retain the canonical path in the human-readable summary
even though the multimodal envelope's ``meta`` dictionary is not stored in
the tool message.
"""
if isinstance(content, str):
stripped = content.strip()
if stripped.startswith(("{", "[")):
# JSON-looking content: parse first, never regex-scan the raw
# text. JSON escaping doubles backslashes, so a summary-regex hit
# on the raw string would yield ``C:\\\\Users\\\\...`` — a path
# that exists nowhere. Fail closed on unparseable JSON (tool
# output truncation) rather than repair to a corrupted path.
try:
payload = json.loads(stripped)
except Exception:
return
if isinstance(payload, (dict, list)):
yield from _iter_computer_use_capture_paths(payload)
return
for match in _COMPUTER_USE_CAPTURE_SUMMARY_RE.finditer(content):
yield match.group("path").strip()
return
if isinstance(content, list):
for part in content:
yield from _iter_computer_use_capture_paths(part)
return
if not isinstance(content, dict):
return
screenshot_path = content.get("screenshot_path")
if isinstance(screenshot_path, str):
yield screenshot_path
meta = content.get("meta")
if isinstance(meta, dict) and isinstance(meta.get("screenshot_path"), str):
yield meta["screenshot_path"]
# Producer shapes (tools/computer_use/tool.py::_capture_response):
# "content"/"text" — multimodal envelope parts; "text_summary"/"summary" —
# the human-readable summary carrying the "(shareable screenshot saved
# to ...)" line.
for field in ("content", "text", "text_summary", "summary"):
nested = content.get(field)
if isinstance(nested, (str, dict, list)):
yield from _iter_computer_use_capture_paths(nested)
def repair_explicit_computer_use_media_paths(
response: str,
messages: List[Dict[str, Any]],
history_offset: int = 0,
) -> str:
"""Recover model-mangled paths for explicitly requested screenshots.
Repair only an already-explicit ``MEDIA:`` directive whose unique
generated basename case-insensitively matches a canonical screenshot
path from this turn. This does not auto-attach ordinary computer-use
captures, and normal media path validation still runs after the repair.
Fail-open: the repair is cosmetic, so an unexpected error returns the
response unchanged rather than aborting delivery.
"""
try:
return _repair_explicit_computer_use_media_paths_inner(
response, messages, history_offset
)
except Exception:
logger.debug("computer_use media path repair failed", exc_info=True)
return response
def _repair_explicit_computer_use_media_paths_inner(
response: str,
messages: List[Dict[str, Any]],
history_offset: int = 0,
) -> str:
if "MEDIA:" not in response:
return response
if history_offset and len(messages) >= history_offset:
turn_messages = messages[history_offset:]
elif history_offset:
# Compression can invalidate the original slice boundary. Recover the
# current turn from its last user message; fail closed if none
# remains. (Deliberately narrower than the scan-everything fallback
# in gateway/run.py::_collect_auto_append_media_tags — that helper
# decides whether to ATTACH, this one only rewrites paths the model
# already explicitly emitted.)
last_user = next(
(
index
for index in range(len(messages) - 1, -1, -1)
if messages[index].get("role") == "user"
),
None,
)
turn_messages = messages[last_user:] if last_user is not None else []
else:
turn_messages = messages
call_id_names = tool_name_by_call_id(turn_messages)
canonical_by_basename: Dict[str, str] = {}
for msg in turn_messages:
if msg.get("role") not in {"tool", "function"}:
continue
call_id = str(msg.get("tool_call_id") or msg.get("call_id") or "")
tool_name = str(
msg.get("name")
or msg.get("tool_name")
or call_id_names.get(call_id)
or ""
)
if tool_name != "computer_use":
continue
for path in _iter_computer_use_capture_paths(msg.get("content")):
basename = _computer_use_capture_basename(path)
if basename and _ABS_PATH_PREFIX_RE.match(path):
canonical_by_basename[basename] = path
if not canonical_by_basename:
return response
# Lazy on purpose: keeps `import gateway.media_repair` cheap for
# standalone cron processes that may never hit a MEDIA: response.
# No import cycle either way (base.py imports neither this module
# nor gateway.run at module level).
from gateway.platforms.base import BasePlatformAdapter
media_files, _ = BasePlatformAdapter.extract_media(response)
repaired = response
for emitted_path, _is_voice in media_files:
canonical = canonical_by_basename.get(
_computer_use_capture_basename(emitted_path)
)
if canonical and emitted_path != canonical:
repaired = repaired.replace(emitted_path, canonical)
return repaired
+230
View File
@@ -0,0 +1,230 @@
"""Periodic process memory usage logging for the gateway.
Ported from cline/cline#10343 (src/standalone/memory-monitor.ts).
The gateway is a long-lived process that accumulates memory as it caches
agent instances, session transcripts, tool schemas, memory providers, MCP
connections, etc. A slow leak in any of those subsystems is invisible
in a single log line — you only see it by watching RSS climb over hours.
This module emits a single structured ``[MEMORY] ...`` line every N
minutes (default 5) so maintainers investigating a suspected leak can
grep ``agent.log`` / ``gateway.log`` for a time series of RSS + Python
GC stats. The timer runs in a background thread and shuts down cleanly
with the gateway.
Design notes (parity with the Cline port):
* Grep-friendly single-line format beginning ``[MEMORY]``.
* Final snapshot logged on shutdown so "last RSS before exit" is
always in the log.
* Baseline snapshot logged immediately on start.
* Daemon thread — never blocks process exit.
* Uses ``resource`` (stdlib, Linux/macOS) first and falls back to
``psutil`` when ``resource`` isn't available (Windows). Both are
optional; when neither works we emit a single WARNING and disable
the monitor rather than crashing the gateway.
Config: ``logging.memory_monitor`` in ``config.yaml`` — see
``hermes_cli/config.py`` for the defaults block.
"""
from __future__ import annotations
import gc
import logging
import os
import sys
import threading
import time
from typing import Optional
logger = logging.getLogger(__name__)
_BYTES_TO_MB = 1024 * 1024
_monitor_thread: Optional[threading.Thread] = None
_stop_event: Optional[threading.Event] = None
_start_time: Optional[float] = None
_interval_seconds: float = 300.0 # 5 minutes
_lock = threading.Lock()
def _get_rss_mb() -> Optional[int]:
"""Return current process resident set size in MB, or None if unavailable.
Tries ``resource.getrusage`` first (Linux/macOS, no extra deps), then
falls back to ``psutil`` which is an optional hermes-agent dep.
"""
# Linux / macOS — resource is stdlib. On Linux ru_maxrss is in KB,
# on macOS it is in bytes (yes, really). We use it as a cheap
# "current" RSS — ru_maxrss reports the high-water mark for the
# process, which is what you actually want for leak detection.
try:
import resource
maxrss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
if sys.platform == "darwin":
return int(maxrss / _BYTES_TO_MB)
# Linux / other unices: KB
return int(maxrss / 1024)
except Exception:
pass
# Fallback: psutil (Windows, or unusual unix without resource).
try:
import psutil # type: ignore
rss = psutil.Process(os.getpid()).memory_info().rss
return int(rss / _BYTES_TO_MB)
except Exception:
return None
def log_memory_usage(prefix: str = "") -> None:
"""Log current memory usage in a grep-friendly ``[MEMORY] ...`` line.
Safe to call on-demand from any thread at important lifecycle
moments (after shutdown, after context compression, etc.).
Parameters
----------
prefix
Optional extra tag inserted after ``[MEMORY]`` — e.g.
``"baseline"``, ``"shutdown"``.
"""
rss = _get_rss_mb()
uptime = int(time.monotonic() - _start_time) if _start_time else 0
# gc.get_stats() returns per-generation collection counts; the sum
# is a cheap proxy for "how much garbage have we created".
try:
gc_counts = gc.get_count() # (gen0, gen1, gen2)
except Exception:
gc_counts = (0, 0, 0)
# Thread count is a handy correlate when diagnosing thread leaks.
try:
thread_count = threading.active_count()
except Exception:
thread_count = 0
tag = f"{prefix} " if prefix else ""
if rss is None:
logger.info(
"[MEMORY] %srss=unavailable gc=%s threads=%d uptime=%ds",
tag,
gc_counts,
thread_count,
uptime,
)
else:
logger.info(
"[MEMORY] %srss=%dMB gc=%s threads=%d uptime=%ds",
tag,
rss,
gc_counts,
thread_count,
uptime,
)
def _monitor_loop(stop_event: threading.Event, interval: float) -> None:
"""Background thread body — log every ``interval`` seconds until stopped."""
while not stop_event.wait(interval):
try:
log_memory_usage()
except Exception as e:
# Never let the monitor crash the gateway; just log and carry on.
logger.debug("Memory monitor iteration failed: %s", e)
def start_memory_monitoring(interval_seconds: float = 300.0) -> bool:
"""Start periodic memory usage logging in a daemon thread.
Logs immediately to capture a baseline, then every ``interval_seconds``.
Safe to call multiple times — subsequent calls are no-ops while the
first monitor is still running.
Parameters
----------
interval_seconds
How often to log. Default 300s (5 minutes), matching the
upstream cline/cline implementation.
Returns
-------
bool
True if a fresh monitor thread was started, False if one was
already running or if memory introspection isn't available.
"""
global _monitor_thread, _stop_event, _start_time, _interval_seconds
with _lock:
if _monitor_thread is not None and _monitor_thread.is_alive():
return False
# Sanity-check that we can read RSS at all. If neither resource
# nor psutil works, no point spinning a thread that can only log
# "rss=unavailable" forever — warn once and bail.
if _get_rss_mb() is None:
logger.warning(
"[MEMORY] Memory monitoring unavailable: neither resource.getrusage "
"nor psutil could read process RSS — skipping periodic logging.",
)
return False
_start_time = time.monotonic()
_interval_seconds = float(interval_seconds)
_stop_event = threading.Event()
# Baseline snapshot before the loop starts.
log_memory_usage(prefix="baseline")
_monitor_thread = threading.Thread(
target=_monitor_loop,
args=(_stop_event, _interval_seconds),
name="gateway-memory-monitor",
daemon=True,
)
_monitor_thread.start()
logger.info(
"[MEMORY] Periodic memory monitoring started (interval: %ds)",
int(_interval_seconds),
)
return True
def stop_memory_monitoring(timeout: float = 2.0) -> None:
"""Stop the monitor thread and log a final snapshot.
Safe to call even if ``start_memory_monitoring()`` was never called.
"""
global _monitor_thread, _stop_event
with _lock:
if _stop_event is None or _monitor_thread is None:
return
# Final snapshot before teardown so "last RSS" is always in the log.
try:
log_memory_usage(prefix="shutdown")
except Exception:
pass
_stop_event.set()
thread = _monitor_thread
_monitor_thread = None
_stop_event = None
# Join outside the lock so a stuck log call can't deadlock shutdown.
try:
thread.join(timeout=timeout)
except Exception:
pass
logger.info("[MEMORY] Periodic memory monitoring stopped")
def is_running() -> bool:
"""True if the background monitor thread is alive."""
with _lock:
return _monitor_thread is not None and _monitor_thread.is_alive()
+203
View File
@@ -0,0 +1,203 @@
"""Memory status rollup for ``/api/status`` (NS-656).
The gateway already *produces* every memory-pressure signal a user would
want to know about, but all of it dies in log files:
* :func:`gateway.shutdown_watchdog.write_loop_heartbeat` embeds a
:func:`gateway.lifecycle_ledger.sample_memory` snapshot (gateway RSS +
system MemAvailable/MemTotal + swap) in ``state/gateway.heartbeat``
every 30 seconds.
* :func:`gateway.lifecycle_ledger.record_startup` detects an unclean
previous death and flags ``suspected_oom`` — but only into
``gateway-exit-diag.log`` and a WARNING line.
* ``gateway/agent_cache_pressure.py`` evicts transcripts under pressure,
again log-only.
So a hosted agent can be OOM-killed hourly (the BlueAtlas incident,
NS-608) while its dashboard and the NAS agent card both look perfectly
healthy. This module is the read side that closes the gap: it distills
the *already-persisted* heartbeat + lifecycle sentinel into a compact,
public-safe block that ``/api/status`` can serve to the dashboard SPA
and the NAS availability sweep — no new sampling, no IPC with the
gateway process, just two small file reads.
Public-safety note: ``/api/status`` is an unauthenticated liveness probe
(``PUBLIC_API_PATHS``), which is exactly why NAS can consume it. This
block therefore carries only coarse numbers (MB granularity), enums, and
booleans — the same disclosure class as the existing ``active_agents``
count and ``nous_session_valid`` field (which was added for the same
NAS-sweep audience).
Everything here is best-effort and read-only: a missing/corrupt file
degrades to ``pressure="unknown"`` rather than raising into the status
endpoint.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Pressure thresholds on system MemAvailable. ``critical`` deliberately
# mirrors the lifecycle ledger's OOM-suspicion heuristics
# (:data:`gateway.lifecycle_ledger._LOW_MEM_AVAILABLE_KIB` /
# ``_LOW_MEM_AVAILABLE_FRACTION``): if a memory level would make a
# subsequent unclean death "suspected OOM", the user should already have
# been warned at that level while the process was still alive.
_CRITICAL_AVAILABLE_KIB = 64 * 1024 # < 64 MiB available
_CRITICAL_AVAILABLE_FRACTION = 0.05 # < 5% of MemTotal
_ELEVATED_AVAILABLE_KIB = 128 * 1024 # < 128 MiB available
_ELEVATED_AVAILABLE_FRACTION = 0.15 # < 15% of MemTotal
# A heartbeat older than this no longer describes the present. The writer
# cadence is 30s (DEFAULT_HEARTBEAT_INTERVAL_S); 150s of slack tolerates a
# briefly stalled loop without letting a long-dead gateway's last sample
# masquerade as current pressure.
_HEARTBEAT_FRESH_TTL_S = 150.0
_KIB_PER_MB = 1024
def _mb(kib: Any) -> Optional[int]:
if isinstance(kib, bool) or not isinstance(kib, int) or kib < 0:
return None
return kib // _KIB_PER_MB
def _parse_iso(value: Any) -> Optional[datetime]:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def classify_pressure(
available_kib: Any, total_kib: Any
) -> str:
"""Map a MemAvailable/MemTotal pair to ``ok``/``elevated``/``critical``.
``unknown`` when the sample is missing or malformed — the caller must
not treat "we could not read it" as "memory is fine".
"""
if (
isinstance(available_kib, bool)
or not isinstance(available_kib, int)
or available_kib < 0
):
return "unknown"
fraction: Optional[float] = None
if (
not isinstance(total_kib, bool)
and isinstance(total_kib, int)
and total_kib > 0
):
fraction = available_kib / total_kib
if available_kib < _CRITICAL_AVAILABLE_KIB or (
fraction is not None and fraction < _CRITICAL_AVAILABLE_FRACTION
):
return "critical"
if available_kib < _ELEVATED_AVAILABLE_KIB or (
fraction is not None and fraction < _ELEVATED_AVAILABLE_FRACTION
):
return "elevated"
return "ok"
def _read_heartbeat(home: Optional[Path]) -> Optional[Dict[str, Any]]:
try:
from gateway.lifecycle_ledger import _read_json
from gateway.shutdown_watchdog import get_loop_heartbeat_path
return _read_json(get_loop_heartbeat_path(home))
except Exception:
return None
def _read_sentinel(home: Optional[Path]) -> Optional[Dict[str, Any]]:
try:
from gateway.lifecycle_ledger import (
_read_json,
get_lifecycle_sentinel_path,
)
return _read_json(get_lifecycle_sentinel_path(home))
except Exception:
return None
def collect_memory_status(
home: Optional[Path] = None,
*,
now: Optional[datetime] = None,
) -> Dict[str, Any]:
"""Build the ``memory`` block for ``/api/status``.
``home`` scopes the read to a profile's HERMES_HOME (the status
endpoint's ``?profile=`` handling passes it through); ``None`` means
the active profile. ``now`` is injectable for tests.
Always returns a dict — a gateway that is down, has never written a
heartbeat, or whose files are corrupt yields
``{"pressure": "unknown", ...}`` with whatever fields could still be
recovered. Never raises.
"""
moment = now or datetime.now(timezone.utc)
status: Dict[str, Any] = {
"pressure": "unknown",
"gateway_rss_mb": None,
"system_total_mb": None,
"system_available_mb": None,
"swap_used_mb": None,
"sampled_at": None,
"last_boot_unclean": False,
"last_boot_suspected_oom": False,
# Identity of the CURRENT gateway life (the sentinel's started_at).
# A suspected-OOM restart writes a fresh sentinel, so this changes on
# every boot — the dashboard keys banner dismissal on it so that
# acknowledging one OOM restart does not mute reports of the NEXT
# one (the hourly-restart-loop case is exactly the one that matters).
"boot_id": None,
}
heartbeat = _read_heartbeat(home)
if heartbeat:
sampled_at = _parse_iso(heartbeat.get("updated_at"))
mem = heartbeat.get("mem")
if isinstance(mem, dict):
status["gateway_rss_mb"] = _mb(mem.get("rss_kib"))
status["system_total_mb"] = _mb(mem.get("mem_total_kib"))
status["system_available_mb"] = _mb(mem.get("mem_available_kib"))
status["swap_used_mb"] = _mb(mem.get("swap_used_kib"))
if sampled_at is not None:
status["sampled_at"] = sampled_at.isoformat()
age_s = (moment - sampled_at).total_seconds()
if 0 <= age_s <= _HEARTBEAT_FRESH_TTL_S:
status["pressure"] = classify_pressure(
mem.get("mem_available_kib"),
mem.get("mem_total_kib"),
)
# else: stale sample — numbers are still reported (they are
# honest about *when* via sampled_at) but pressure stays
# "unknown" so a dead gateway's final gasp cannot render a
# live "critical" banner forever.
sentinel = _read_sentinel(home)
if sentinel:
status["last_boot_unclean"] = bool(sentinel.get("prior_unclean_exit"))
status["last_boot_suspected_oom"] = bool(
sentinel.get("prior_suspected_oom")
)
started_at = sentinel.get("started_at")
if isinstance(started_at, str) and started_at:
status["boot_id"] = started_at
return status
+166
View File
@@ -0,0 +1,166 @@
"""Helpers for rendering gateway message timestamps exactly once.
Gateway messages need timestamps in the LLM context for temporal awareness, but
persisted message content should stay clean so replay does not accumulate
``[timestamp] [timestamp] ...`` prefixes across turns.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Any, Optional, Tuple
# Current gateway format: [Tue 2026-04-28 13:40:53 CEST]
_HUMAN_TIMESTAMP_RE = re.compile(
r"^\[(?P<dow>[A-Z][a-z]{2}) "
r"(?P<date>\d{4}-\d{2}-\d{2}) "
r"(?P<time>\d{2}:\d{2}:\d{2})"
r"(?: (?P<tz>[A-Za-z0-9_+\-/:]+))?\]\s*"
)
# Older gateway format: [2026-04-13T17:02:06+0200] or [+02:00]
_ISO_TIMESTAMP_RE = re.compile(
r"^\[(?P<iso>\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*"
)
def coerce_message_timestamp(ts_value: Any, tz=None) -> Optional[float]:
"""Coerce a timestamp-like value to Unix epoch seconds.
Accepts Unix epoch numbers, datetime objects, ISO strings, and the gateway's
bracketed human-readable timestamp format. Returns ``None`` when the value
cannot be interpreted.
"""
if ts_value is None:
return None
if isinstance(ts_value, (int, float)):
return float(ts_value)
if hasattr(ts_value, "timestamp"):
try:
return float(ts_value.timestamp())
except Exception:
return None
if isinstance(ts_value, str):
text = ts_value.strip()
if not text:
return None
parsed = _parse_timestamp_prefix(text, tz=tz)
if parsed is not None:
return parsed
try:
return float(text)
except (TypeError, ValueError):
pass
try:
dt = datetime.fromisoformat(text)
except (TypeError, ValueError):
try:
dt = datetime.strptime(text, "%Y-%m-%dT%H:%M:%S%z")
except (TypeError, ValueError):
return None
if dt.tzinfo is None:
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
return None
def format_message_timestamp(ts_value: Any, tz=None) -> str:
"""Format a timestamp value as ``[Tue 2026-04-28 13:40:53 CEST]``."""
epoch = coerce_message_timestamp(ts_value, tz=tz)
if epoch is None:
return ""
if tz is not None:
dt = datetime.fromtimestamp(epoch, tz=tz)
else:
dt = datetime.fromtimestamp(epoch).astimezone()
return "[" + dt.strftime("%a %Y-%m-%d %H:%M:%S %Z") + "]"
def strip_leading_message_timestamps(content: str, tz=None) -> Tuple[str, Optional[float]]:
"""Strip one or more leading gateway timestamp prefixes from ``content``.
Returns ``(clean_content, embedded_epoch)``. If multiple timestamp prefixes
are present, the timestamp closest to the actual message text wins. That
preserves the original platform-send time for legacy contaminated rows like
``[processing time] [platform time] [sender] message``.
"""
if not isinstance(content, str) or not content:
return content, None
text = content
embedded_epoch: Optional[float] = None
while True:
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
if not match:
break
parsed = _parse_timestamp_match(match, tz=tz)
if parsed is not None:
embedded_epoch = parsed
text = text[match.end():]
return text, embedded_epoch
def render_user_content_with_timestamp(content: str, ts_value: Any = None, tz=None) -> str:
"""Render a user message for LLM context with exactly one timestamp prefix.
Existing leading timestamp prefixes are removed first. If such a prefix was
present, its parsed time wins over ``ts_value``; otherwise ``ts_value`` is
formatted and prepended. If no timestamp is available, the cleaned content is
returned unchanged.
"""
clean_content, embedded_epoch = strip_leading_message_timestamps(content, tz=tz)
effective_ts = embedded_epoch if embedded_epoch is not None else ts_value
prefix = format_message_timestamp(effective_ts, tz=tz)
if not prefix:
return clean_content
if clean_content:
return f"{prefix} {clean_content}"
return prefix
def _parse_timestamp_prefix(text: str, tz=None) -> Optional[float]:
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
if not match:
return None
return _parse_timestamp_match(match, tz=tz)
def _parse_timestamp_match(match: re.Match, tz=None) -> Optional[float]:
if "iso" in match.groupdict() and match.group("iso"):
iso_text = match.group("iso")
try:
dt = datetime.fromisoformat(iso_text)
except ValueError:
try:
dt = datetime.strptime(iso_text, "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
return None
if dt.tzinfo is None:
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
date_part = match.group("date")
time_part = match.group("time")
try:
dt = datetime.strptime(f"{date_part} {time_part}", "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
+227
View File
@@ -0,0 +1,227 @@
"""
Session mirroring for cross-platform message delivery.
When a message is sent to a platform (via send_message or cron delivery),
this module appends a "delivery-mirror" record to the target session's
transcript so the receiving-side agent has context about what was sent.
Standalone -- works from CLI, cron, and gateway contexts without needing
the full SessionStore machinery.
"""
import json
import logging
from datetime import datetime
from typing import Optional
from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
_SESSIONS_DIR = get_hermes_home() / "sessions"
_SESSIONS_INDEX = _SESSIONS_DIR / "sessions.json"
def mirror_to_session(
platform: str,
chat_id: str,
message_text: str,
source_label: str = "cli",
thread_id: Optional[str] = None,
user_id: Optional[str] = None,
role: str = "assistant",
session_id: Optional[str] = None,
) -> bool:
"""
Append a delivery-mirror message to the target session's transcript.
Finds the gateway session that matches the given platform + chat_id,
then writes a mirror entry to both the JSONL transcript and SQLite DB.
``session_id``: when the caller already KNOWS the exact session (e.g. the
cron in_channel seed, which just created the row via
``get_or_create_session``), pass it to skip the origin-scan heuristics
entirely. ``_find_session_id`` matches by origin (chat_id + user
preference with a multi-candidate bail-out), which is correct for
"mirror into whatever conversation lives here" callers but WRONG for a
caller holding the precise target — on a populated chat (flat session +
N per-message thread sessions sharing one chat_id) the scan can refuse
to guess and silently drop the mirror (live failure, Alice 2026-08-19:
'in_channel seed did NOT land').
``role`` defaults to ``"assistant"`` — correct for the interactive
``send_message`` mirror, where the mirrored text is the agent's own
outgoing reply (a genuine assistant turn). Callers mirroring text that is
NOT the agent speaking — e.g. a cron brief delivered out-of-band — must
pass ``role="user"``: the ``mirror``/``mirror_source`` metadata is dropped
at the SQLite boundary (only role+content persist), so on replay an
assistant-role mirror is indistinguishable from a real assistant turn and
produces ``assistant → assistant`` pairs that break strict-alternation
providers (issue #2221). A user-role mirror collapses safely via
``repair_message_sequence``'s consecutive-user merge on every provider.
Returns True if mirrored successfully, False if no matching session or error.
All errors are caught -- this is never fatal.
"""
try:
if not session_id:
session_id = _find_session_id(
platform,
str(chat_id),
thread_id=thread_id,
user_id=user_id,
)
if not session_id:
logger.warning(
"Mirror: no session found for %s:%s thread=%s user=%s "
"(explicit_id=none, origin-scan bailed)",
platform,
chat_id,
thread_id,
user_id,
)
return False
mirror_msg = {
"role": role,
"content": message_text,
"timestamp": datetime.now().isoformat(),
"mirror": True,
"mirror_source": source_label,
}
_append_to_sqlite(session_id, mirror_msg)
logger.debug("Mirror: wrote to session %s (from %s)", session_id, source_label)
return True
except Exception as e:
# WARNING with the exception: a silent mirror drop IS the cron
# continuation-amnesia bug (Alice 2026-08-19 — the seed's own
# deterministic session_id was in hand and the append STILL failed
# invisibly at debug level).
logger.warning(
"Mirror failed for %s:%s thread=%s user=%s session=%s: %s",
platform,
chat_id,
thread_id,
user_id,
session_id,
e,
)
return False
def _find_session_id(
platform: str,
chat_id: str,
thread_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Optional[str]:
"""
Find the active session_id for a platform + chat_id pair.
Queries state.db gateway session rows (primary source since #9006);
falls back to scanning sessions.json for pre-migration databases.
DM session keys don't embed the chat_id (e.g. "agent:main:telegram:dm"),
so we match on the persisted chat origin, not the key.
When *user_id* is provided, prefer exact sender matches. If multiple
same-chat candidates exist and none matches the user, return None instead
of guessing and contaminating another participant's session.
"""
# Primary: state.db
try:
from hermes_state import get_shared_session_db
db = get_shared_session_db()
try:
finder = getattr(db, "find_session_by_origin", None)
if callable(finder):
session_id = finder(
platform=platform,
chat_id=chat_id,
thread_id=thread_id,
user_id=user_id,
)
if session_id:
return str(session_id)
finally:
from hermes_state import release_or_close
release_or_close(db)
except Exception as e:
logger.debug("Mirror state.db session lookup failed: %s", e)
# Fallback: sessions.json (pre-migration databases)
if not _SESSIONS_INDEX.exists():
return None
try:
with open(_SESSIONS_INDEX, encoding="utf-8") as f:
data = json.load(f)
except Exception:
return None
platform_lower = platform.lower()
candidates = []
for _key, entry in data.items():
# Skip documentation/metadata sentinels (keys starting with "_", e.g.
# the gateway's "_README" note) — they are not session entries.
if str(_key).startswith("_") or not isinstance(entry, dict):
continue
origin = entry.get("origin") or {}
entry_platform = (origin.get("platform") or entry.get("platform", "")).lower()
if entry_platform != platform_lower:
continue
origin_chat_id = str(origin.get("chat_id", ""))
if origin_chat_id == str(chat_id):
origin_thread_id = origin.get("thread_id")
if thread_id is not None and str(origin_thread_id or "") != str(thread_id):
continue
candidates.append(entry)
if not candidates:
return None
if user_id:
exact_user_matches = [
entry for entry in candidates
if str((entry.get("origin") or {}).get("user_id") or "") == str(user_id)
]
if exact_user_matches:
candidates = exact_user_matches
elif len(candidates) > 1:
return None
elif len(candidates) > 1:
distinct_user_ids = {
str((entry.get("origin") or {}).get("user_id") or "").strip()
for entry in candidates
if str((entry.get("origin") or {}).get("user_id") or "").strip()
}
if len(distinct_user_ids) > 1:
return None
best_entry = max(candidates, key=lambda entry: entry.get("updated_at", ""))
return best_entry.get("session_id")
def _append_to_sqlite(session_id: str, message: dict) -> None:
"""Append a message to the SQLite session database."""
db = None
try:
from hermes_state import get_shared_session_db
db = get_shared_session_db()
db.append_message(
session_id=session_id,
role=message.get("role", "assistant"),
content=message.get("content"),
)
except Exception as e:
logger.debug("Mirror SQLite write failed: %s", e)
finally:
if db is not None:
from hermes_state import release_or_close
release_or_close(db)
+936
View File
@@ -0,0 +1,936 @@
"""
DM Pairing System
Code-based approval flow for authorizing new users on messaging platforms.
Instead of static allowlists with user IDs, unknown users receive a one-time
pairing code that the bot owner approves via the CLI.
Security features (based on OWASP + NIST SP 800-63-4 guidance):
- 8-char codes from 32-char unambiguous alphabet (no 0/O/1/I)
- Cryptographic randomness via secrets.choice()
- 1-hour code expiry
- Max 3 pending codes per platform
- Rate limiting: 1 request per user per 10 minutes
- Lockout after 5 failed approval attempts (1 hour)
- File permissions: chmod 0600 on all data files
- Codes are never logged to stdout
Storage: ~/.hermes/pairing/
"""
import hashlib
import json
import logging
import os
import secrets
import tempfile
import threading
import time
from pathlib import Path
from typing import Optional
from gateway.whatsapp_identity import (
expand_whatsapp_aliases,
normalize_whatsapp_identifier,
)
from hermes_constants import (
get_default_hermes_root,
get_hermes_dir,
get_hermes_home,
)
from utils import atomic_replace
logger = logging.getLogger(__name__)
# Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
CODE_LENGTH = 8
# Timing constants
CODE_TTL_SECONDS = 3600 # Codes expire after 1 hour
RATE_LIMIT_SECONDS = 600 # 1 request per user per 10 minutes
LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures
# Limits
MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform
MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout
# Default (non-profile-scoped) pairing directory. Left unresolved (``None``)
# here rather than computed eagerly: this module is imported once by the
# long-lived gateway process at container/process boot, and computing the
# path eagerly freezes it to whatever HERMES_HOME/profile context existed
# at that exact import moment for the rest of the process's lifetime --
# even if a context-local override (see hermes_constants.set_hermes_home_override)
# is established afterward. A freshly-started, short-lived process (e.g. the
# ``hermes pairing`` CLI) re-imports this module later with the final
# environment already in place, so it never observes the stale value -- the
# resulting asymmetry is what made pending pairing codes issued by the
# gateway unrecoverable while CLI-side writes to the same directory kept
# working (NousResearch/hermes-agent#93449).
#
# ``_default_pairing_dir()`` below resolves this fresh on every call in
# production. Tests patch this attribute directly to a concrete path for
# isolation (e.g. ``patch("gateway.pairing.PAIRING_DIR", tmp_path)``); that
# continues to work unchanged, since a patched (non-``None``) value takes
# precedence over recomputing.
PAIRING_DIR = None
def _default_pairing_dir() -> Path:
"""Resolve the default (non-profile-scoped) pairing directory.
Recomputed on every call rather than cached at import time -- see the
``PAIRING_DIR`` comment above for why. Honors ``PAIRING_DIR`` when a
caller (typically a test) has explicitly set it to a concrete path.
"""
if PAIRING_DIR is not None:
return PAIRING_DIR
return get_hermes_dir("platforms/pairing", "pairing")
# Platform value -> its per-platform allowlist env var. When an operator has
# already configured an allowlist for a platform, approving a pairing code also
# writes the user into that allowlist (and revoking removes them), so the
# operator's own list stays the single visible/editable source of truth instead
# of drifting from an opaque approved.json (#23778 consolidation, option i).
# Platforms absent from this map (or with no allowlist configured) keep the
# pairing store as the sole grant record, honored by the authz union.
_PLATFORM_ALLOWLIST_ENV = {
"telegram": "TELEGRAM_ALLOWED_USERS",
"discord": "DISCORD_ALLOWED_USERS",
"whatsapp": "WHATSAPP_ALLOWED_USERS",
"whatsapp_cloud": "WHATSAPP_CLOUD_ALLOWED_USERS",
"slack": "SLACK_ALLOWED_USERS",
"signal": "SIGNAL_ALLOWED_USERS",
"email": "EMAIL_ALLOWED_USERS",
"sms": "SMS_ALLOWED_USERS",
"mattermost": "MATTERMOST_ALLOWED_USERS",
"matrix": "MATRIX_ALLOWED_USERS",
"dingtalk": "DINGTALK_ALLOWED_USERS",
"feishu": "FEISHU_ALLOWED_USERS",
"wecom": "WECOM_ALLOWED_USERS",
"wecom_callback": "WECOM_CALLBACK_ALLOWED_USERS",
"weixin": "WEIXIN_ALLOWED_USERS",
"bluebubbles": "BLUEBUBBLES_ALLOWED_USERS",
"qqbot": "QQ_ALLOWED_USERS",
"yuanbao": "YUANBAO_ALLOWED_USERS",
}
def _allowlist_env_for_platform(platform: str) -> Optional[str]:
"""Return the per-platform allowlist env var name, or None.
Falls back to the platform registry for plugin platforms so a plugin's
own ``allowed_users_env`` is honored too.
"""
platform = (platform or "").lower().strip()
env_var = _PLATFORM_ALLOWLIST_ENV.get(platform)
if env_var:
return env_var
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform)
if entry and entry.allowed_users_env:
return entry.allowed_users_env
except Exception:
pass
return None
def _split_allowlist(raw: str) -> list:
return [uid.strip() for uid in raw.split(",") if uid.strip()]
def _platform_uses_whatsapp_identity(platform: str) -> bool:
"""True for Baileys WhatsApp and Meta Cloud — same phone/JID identity rules."""
return (platform or "").strip().lower() in {"whatsapp", "whatsapp_cloud"}
def _normalize_user_id(platform: str, user_id: str) -> str:
"""Normalize platform-specific user IDs before persisting / comparing them."""
raw_user_id = str(user_id or "").strip()
if _platform_uses_whatsapp_identity(platform):
return normalize_whatsapp_identifier(raw_user_id) or raw_user_id
return raw_user_id
def _user_id_aliases(platform: str, user_id: str) -> set[str]:
"""Return all known equivalent user IDs for auth / allowlist matching."""
raw_user_id = str(user_id or "").strip()
if not raw_user_id:
return set()
aliases = {raw_user_id, _normalize_user_id(platform, raw_user_id)}
if _platform_uses_whatsapp_identity(platform):
aliases.update(expand_whatsapp_aliases(raw_user_id))
aliases.discard("")
return aliases
def _user_ids_match(platform: str, left: str, right: str) -> bool:
"""Return True when two user IDs represent the same principal."""
left_aliases = _user_id_aliases(platform, left)
right_aliases = _user_id_aliases(platform, right)
return bool(left_aliases and right_aliases and (left_aliases & right_aliases))
def _read_allowlist_env(env_var: str) -> str:
"""Read a platform allowlist env var through the profile secret scope.
Under multiplexing the process env may hold ANOTHER profile's allowlist
(first-writer-wins YAML→env bridges), so reads must honor the installed
scope's verdict — including a scoped miss returning empty rather than
borrowing the process value. Unscoped callers (single-profile CLI /
admin endpoints) keep the legacy ``os.getenv`` read.
The grant mirror below writes through ``hermes_cli.config.save_env_value``
/ ``remove_env_value``: the file target is the active profile's ``.env``
(``get_env_path()`` honors the profile-home override) and, under
multiplexing, the in-process publish updates the installed scope mapping
rather than the shared ``os.environ`` (#88441).
"""
try:
from agent.secret_scope import UnscopedSecretError, get_secret
try:
return (get_secret(env_var) or "").strip()
except UnscopedSecretError:
pass
except Exception:
pass
return (os.getenv(env_var) or "").strip()
def _sync_allowlist_add(platform: str, user_id: str) -> None:
"""Add ``user_id`` to the platform allowlist env var IF one is configured.
Option (i): only materialize the grant into the allowlist when the operator
already runs an allowlist for this platform. On an open gateway (no
allowlist) we do nothing — the pairing store remains the grant record and
the authz union honors it, so we never silently convert an open gateway into
a locked one on first pairing.
"""
env_var = _allowlist_env_for_platform(platform)
if not env_var:
return
current = _read_allowlist_env(env_var)
if not current:
return # No allowlist configured — leave the gateway open (option i).
ids = _split_allowlist(current)
if "*" in ids or str(user_id) in ids:
return # Already covered.
ids.append(str(user_id))
try:
from hermes_cli.config import save_env_value
save_env_value(env_var, ",".join(ids))
except Exception:
# Best-effort: the pairing store grant still authorizes via the union,
# so a failure here degrades to "grant recorded but not mirrored".
pass
def _iter_live_gateway_adapters():
"""Yield adapters from the in-process GatewayRunner, if one is running."""
try:
from gateway.run import _gateway_runner_ref
runner = _gateway_runner_ref()
except Exception:
return
if runner is None:
return
adapters = getattr(runner, "adapters", None) or {}
for adapter in adapters.values():
if adapter is not None:
yield adapter
profile_adapters = getattr(runner, "_profile_adapters", None) or {}
for mapping in profile_adapters.values():
for adapter in (mapping or {}).values():
if adapter is not None:
yield adapter
def _adapter_platform_name(adapter) -> str:
platform = getattr(adapter, "platform", None)
if platform is not None:
value = getattr(platform, "value", None)
if value:
return str(value).strip().lower()
name = getattr(adapter, "name", None)
return str(name or "").strip().lower()
def _purge_allowlist_entries(entries, platform: str, user_id: str):
"""Drop alias-equivalent allowlist entries while preserving ``*``."""
if entries is None:
return entries
if isinstance(entries, str):
parts = _split_allowlist(entries)
remaining = [
part for part in parts
if part == "*" or not _user_ids_match(platform, part, str(user_id))
]
return ",".join(remaining)
if isinstance(entries, (set, frozenset)):
return {
entry for entry in entries
if str(entry).strip() == "*"
or not _user_ids_match(platform, str(entry), str(user_id))
}
if isinstance(entries, (list, tuple)):
return [
entry for entry in entries
if str(entry).strip() == "*"
or not _user_ids_match(platform, str(entry), str(user_id))
]
return entries
def _sync_live_adapter_allowlist_remove(platform: str, user_id: str) -> None:
"""Clear revoked principals from in-process adapter allowlist snapshots.
``WhatsAppAdapter`` (and Cloud) snapshot ``_allow_from`` at construction.
Pairing revoke updates ``WHATSAPP_ALLOWED_USERS`` / cloud env, but when the
revoked principal was the sole entry the env key is removed entirely.
Intake must not keep authorizing from the stale snapshot until restart.
"""
platform_name = (platform or "").strip().lower()
if not platform_name or not str(user_id or "").strip():
return
for adapter in _iter_live_gateway_adapters():
if _adapter_platform_name(adapter) != platform_name:
continue
if hasattr(adapter, "_allow_from"):
try:
adapter._allow_from = _purge_allowlist_entries(
set(adapter._allow_from or ()), platform_name, user_id
)
except Exception:
pass
extra = getattr(getattr(adapter, "config", None), "extra", None)
if isinstance(extra, dict) and "allow_from" in extra:
try:
extra["allow_from"] = _purge_allowlist_entries(
extra.get("allow_from"), platform_name, user_id
)
except Exception:
pass
def _sync_allowlist_remove(platform: str, user_id: str) -> None:
"""Remove ``user_id`` (and WhatsApp alias equivalents) from the allowlist.
Matching must mirror PairingStore / authz WhatsApp alias rules: approve
mirrors a normalized phone into ``WHATSAPP_ALLOWED_USERS``, while revoke
is often invoked with a JID or device-suffix form. Exact-string delete
would leave the allowlist entry and keep the sender authorized.
Also clears matching entries from any in-process platform adapter
``_allow_from`` snapshot so sole-entry revocation is effective without a
gateway restart.
"""
env_var = _allowlist_env_for_platform(platform)
if not env_var:
return
current = _read_allowlist_env(env_var)
if not current:
return # No allowlist configured — do not touch config-only snapshots.
ids = _split_allowlist(current)
# Never strip a wildcard grant; drop every entry that aliases-matches.
remaining = [
i for i in ids
if i == "*" or not _user_ids_match(platform, i, str(user_id))
]
if len(remaining) == len(ids):
return # Not present.
try:
from hermes_cli.config import save_env_value, remove_env_value
if remaining:
save_env_value(env_var, ",".join(remaining))
else:
remove_env_value(env_var)
except Exception:
pass
_sync_live_adapter_allowlist_remove(platform, user_id)
def _load_json_file(path: Path) -> dict:
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
return {}
def _merge_pairing_dir(active_dir: Path, alternate_dir: Path) -> None:
"""Merge split legacy/new pairing data into the active PairingStore dir.
Older installs use ``{HERMES_HOME}/pairing`` while newer code/docs may
write ``{HERMES_HOME}/platforms/pairing``. If both directories exist, the
gateway must not silently ignore approved users sitting in the inactive
location; otherwise already-paired Feishu users get asked for a fresh code.
"""
if not alternate_dir.exists() or active_dir.resolve() == alternate_dir.resolve():
return
active_dir.mkdir(parents=True, exist_ok=True)
for src in alternate_dir.glob("*.json"):
if not src.is_file():
continue
dest = active_dir / src.name
merged = _load_json_file(src)
if not merged:
continue
current = _load_json_file(dest)
before = dict(current)
# Active data wins on key conflict; otherwise union the inactive data.
merged.update(current)
if merged != before:
_secure_write(dest, json.dumps(merged, indent=2, ensure_ascii=False))
def _migrate_split_pairing_dirs(
*,
home: Optional[Path] = None,
active: Optional[Path] = None,
) -> None:
home = home or get_hermes_home()
old_dir = home / "pairing"
new_dir = home / "platforms" / "pairing"
active = active if active is not None else _default_pairing_dir()
alternate = new_dir if active.resolve() == old_dir.resolve() else old_dir
_merge_pairing_dir(active, alternate)
def _secure_write(path: Path, data: str) -> None:
"""Write data to file with restrictive permissions (owner read/write only).
Uses a temp-file + atomic rename so readers always see either the old
complete file or the new one — never a partial write.
"""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, path)
try:
os.chmod(path, 0o600)
except OSError:
pass # Windows doesn't support chmod the same way
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
class PairingStore:
"""
Manages pairing codes and approved user lists.
Data files per platform:
- {platform}-pending.json : pending pairing requests
- {platform}-approved.json : approved (paired) users
- _rate_limits.json : rate limit tracking
When constructed with ``profile="<name>"``, storage resolves from that
profile's own HERMES_HOME using the same legacy/consolidated layout rules
as ``hermes -p <name> pairing ...``. This keeps multiplex gateways and
profile-scoped CLI approvals on one whitelist. Without a profile, storage
is the global pairing directory for the current HERMES_HOME.
"""
def __init__(self, profile: Optional[str] = None):
# Resolve storage directory lazily — tests use a temp HERMES_HOME
# and PairingStore may be constructed before the env is set.
if profile:
root = get_default_hermes_root()
profile_home = (
root
if profile == "default"
else root / "profiles" / profile
)
self._dir = get_hermes_dir(
"platforms/pairing",
"pairing",
home=profile_home,
)
else:
self._dir = _default_pairing_dir()
self._dir.mkdir(parents=True, exist_ok=True)
if profile:
# Explicit stores must resolve exactly as a standalone
# ``hermes -p <profile> pairing ...`` process does. Merge the
# alternate old/new layout so upgrades cannot split approvals.
_migrate_split_pairing_dirs(home=profile_home, active=self._dir)
else:
# Heal installs whose global pairing data ended up split across
# the legacy and new directories.
_migrate_split_pairing_dirs()
# Protects all read-modify-write cycles. The gateway runs multiple
# platform adapters concurrently in threads sharing one PairingStore.
self._lock = threading.RLock()
self._profile = profile # for diagnostics / log lines
@property
def profile(self) -> Optional[str]:
"""Profile name this store is scoped to, or None for the global store."""
return self._profile
def _pending_path(self, platform: str) -> Path:
return self._dir / f"{platform}-pending.json"
def _approved_path(self, platform: str) -> Path:
return self._dir / f"{platform}-approved.json"
def _rate_limit_path(self) -> Path:
return self._dir / "_rate_limits.json"
def _load_json(self, path: Path) -> dict:
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except PermissionError as e:
# Surface this loudly: a 0600 file owned by a different user
# (classic Docker symptom: `docker exec` runs as root and writes
# the file, then the gateway process — running as `hermes` after
# gosu drop — can't read it) would otherwise be swallowed by
# the generic OSError branch below, silently leaving the user
# marked unauthorized. See issue #10270.
try:
st = path.stat()
owner_info = f"owner_uid={st.st_uid} mode={oct(st.st_mode)[-4:]}"
except OSError:
owner_info = "<stat failed>"
# os.geteuid doesn't exist on Windows; the Docker scenario is
# POSIX-only, but the gateway (and this fallback) runs anywhere.
euid = os.geteuid() if hasattr(os, "geteuid") else "n/a"
logger.warning(
"Pairing file %s exists but is not readable as uid=%s (%s; %s). "
"If you ran `docker exec <container> hermes pairing approve ...` as root, "
"re-run with `docker exec -u hermes <container> ...` and "
"chown the existing file to the hermes user, or restart the "
"container so the entrypoint can fix ownership.",
path, euid, owner_info, e,
)
return {}
except (json.JSONDecodeError, OSError):
return {}
return {}
def _save_json(self, path: Path, data: dict) -> None:
_secure_write(path, json.dumps(data, indent=2, ensure_ascii=False))
def _normalize_user_id(self, platform: str, user_id: str) -> str:
"""Normalize platform-specific user IDs before persisting them."""
return _normalize_user_id(platform, user_id)
def _user_id_aliases(self, platform: str, user_id: str) -> set[str]:
"""Return all known equivalent user IDs for auth/rate-limit checks."""
return _user_id_aliases(platform, user_id)
def _user_ids_match(self, platform: str, left: str, right: str) -> bool:
"""Return True when two user IDs represent the same principal."""
return _user_ids_match(platform, left, right)
# ----- Approved users -----
def is_approved(self, platform: str, user_id: str) -> bool:
"""Check if a user is approved (paired) on a platform."""
approved = self._load_json(self._approved_path(platform))
for approved_user_id in approved:
if self._user_ids_match(platform, approved_user_id, user_id):
return True
return False
def list_approved(self, platform: str = None) -> list:
"""List approved users, optionally filtered by platform."""
results = []
platforms = [platform] if platform else self._all_platforms("approved")
for p in platforms:
approved = self._load_json(self._approved_path(p))
for uid, info in approved.items():
results.append({"platform": p, "user_id": uid, **info})
return results
def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> None:
"""Add a user to the approved list. Must be called under self._lock."""
approved = self._load_json(self._approved_path(platform))
normalized_user_id = self._normalize_user_id(platform, user_id)
duplicate_ids = [
approved_user_id
for approved_user_id in approved
if self._user_ids_match(platform, approved_user_id, normalized_user_id)
]
for approved_user_id in duplicate_ids:
del approved[approved_user_id]
approved[normalized_user_id] = {
"user_name": user_name,
"approved_at": time.time(),
}
self._save_json(self._approved_path(platform), approved)
# Mirror the grant into the operator's allowlist when one is configured
# (option i), so the pairing store and the allowlist stay a single
# visible source of truth. No-op on open gateways.
_sync_allowlist_add(platform, normalized_user_id)
def revoke(self, platform: str, user_id: str) -> bool:
"""Remove a user from the approved list. Returns True if found."""
path = self._approved_path(platform)
with self._lock:
approved = self._load_json(path)
matching_ids = [
approved_user_id
for approved_user_id in approved
if self._user_ids_match(platform, approved_user_id, user_id)
]
if matching_ids:
for approved_user_id in matching_ids:
del approved[approved_user_id]
self._save_json(path, approved)
# Keep the allowlist mirror in sync: revoking a paired user
# also removes the entry the approval added (option i). No-op if
# the user was added to the allowlist by other means.
_sync_allowlist_remove(platform, user_id)
return True
return False
# ----- Pending codes -----
@staticmethod
def _hash_code(code: str, salt: bytes) -> str:
"""Hash a pairing code with the given salt using SHA-256."""
return hashlib.sha256(salt + code.encode("utf-8")).hexdigest()
def _finish_approval(
self, platform: str, pending: dict, matched_key: str, matched_entry: dict
) -> dict:
"""Remove a pending request and approve its user. Must hold self._lock."""
del pending[matched_key]
self._save_json(self._pending_path(platform), pending)
# A successful approval proves the requester is legitimate, so the
# brute-force failure streak must not carry over. Without this,
# isolated mistyped codes accumulate across the gateway's lifetime
# (the counter is persisted in _rate_limits.json and only ever
# reset when a lockout fires) and eventually trip a spurious
# lockout on a single fresh typo — rejecting even a valid code.
self._reset_failed_attempts(platform)
self._approve_user(
platform, matched_entry["user_id"], matched_entry.get("user_name", "")
)
return {
"user_id": matched_entry["user_id"],
"user_name": matched_entry.get("user_name", ""),
}
def generate_code(
self, platform: str, user_id: str, user_name: str = ""
) -> Optional[str]:
"""
Generate a pairing code for a new user.
Returns the code string, or None if:
- User is rate-limited (too recent request)
- Max pending codes reached for this platform
- User/platform is in lockout due to failed attempts
The code is NOT stored in plaintext. Only a salted SHA-256 hash is
persisted so that reading the pending file does not reveal codes.
"""
with self._lock:
self._cleanup_expired(platform)
normalized_user_id = self._normalize_user_id(platform, user_id)
# Check lockout
if self._is_locked_out(platform):
return None
# Check rate limit for this specific user
if self._is_rate_limited(platform, user_id):
return None
# Check max pending
pending = self._load_json(self._pending_path(platform))
if len(pending) >= MAX_PENDING_PER_PLATFORM:
return None
# Generate cryptographically random code
code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH))
# Hash the code with a random salt before storing
salt = os.urandom(16)
code_hash = self._hash_code(code, salt)
# Use a unique entry id as the key (not the code itself)
entry_id = secrets.token_hex(8)
# Store pending request with hashed code
pending[entry_id] = {
"hash": code_hash,
"salt": salt.hex(),
"user_id": normalized_user_id,
"user_name": user_name,
"created_at": time.time(),
}
self._save_json(self._pending_path(platform), pending)
# Record rate limit
self._record_rate_limit(platform, user_id)
return code
def approve_code(self, platform: str, code: str) -> Optional[dict]:
"""
Approve a pairing code. Adds the user to the approved list.
Returns ``{user_id, user_name}`` on success, ``None`` if the code is
invalid/expired OR the platform is currently locked out after
``MAX_FAILED_ATTEMPTS`` failed approvals (#10195). Callers can
disambiguate with ``_is_locked_out(platform)``.
Verification: the user-provided code is hashed with each stored
entry's salt and compared to the stored hash using constant-time
comparison. Pre-hash entries (legacy plaintext-key format from
pre-upgrade pending.json files) are silently ignored — they get
pruned at TTL by ``_cleanup_expired``.
"""
with self._lock:
self._cleanup_expired(platform)
code = code.upper().strip()
# Lockout check — must run before the pending lookup so a
# valid code (e.g. one already sitting in pending) cannot be
# accepted once the lockout fires. Without this, the lockout
# only blocks `generate_code`, not `approve_code` — nullifying
# the brute-force protection for any code already issued.
if self._is_locked_out(platform):
return None
pending = self._load_json(self._pending_path(platform))
# Find the entry whose hash matches the provided code.
# Tolerate legacy plaintext-key entries (no salt/hash) and
# malformed entries — skip them rather than KeyError, so an
# in-place upgrade across an existing pending.json doesn't
# crash on the first approve call. Legacy entries get pruned
# at their TTL by _cleanup_expired.
matched_key = None
matched_entry = None
for entry_id, entry in pending.items():
if not isinstance(entry, dict):
continue
if "salt" not in entry or "hash" not in entry:
continue
try:
salt = bytes.fromhex(entry["salt"])
except ValueError:
continue
candidate_hash = self._hash_code(code, salt)
if secrets.compare_digest(candidate_hash, entry["hash"]):
matched_key = entry_id
matched_entry = entry
break
if matched_key is None:
self._record_failed_attempt(platform)
return None
return self._finish_approval(platform, pending, matched_key, matched_entry)
@staticmethod
def looks_like_request_id(value: str) -> bool:
"""True when ``value`` has the shape of a ``list_pending`` request id.
Request ids are ``secrets.token_hex(8)`` (16 lowercase hex chars);
pairing codes are 8 chars from an unambiguous uppercase alphabet that
excludes every hex letter's ambiguity partner. The two shapes cannot
collide, so callers accepting either can dispatch on this.
"""
value = str(value or "").strip()
return len(value) == 16 and all(c in "0123456789abcdefABCDEF" for c in value)
def approve_request(self, platform: str, request_id: str) -> Optional[dict]:
"""
Approve a pending pairing request by its server-side request id.
This is the grant path for authenticated admin surfaces (``hermes
pairing list``, the dashboard/desktop approve buttons), which show
pending requests but must never reveal the one-time code DM'd to the
user. Returns ``{user_id, user_name}`` on success, ``None`` for an
unknown/expired request id.
Unlike :meth:`approve_code` this does NOT count a miss toward the
brute-force lockout, and is not itself gated by one. The lockout
protects the 8-char code space against guessing over a messaging
channel; a request id is only ever obtained by an admin already
authenticated to this store, so a stale id means "the row you clicked
expired", not an attack. Counting it here let a few GUI clicks on a
stale list lock the operator out of the CLI's code path too.
"""
with self._lock:
self._cleanup_expired(platform)
request_id = str(request_id or "").strip().lower()
if not request_id:
return None
pending = self._load_json(self._pending_path(platform))
for entry_id, entry in pending.items():
if not isinstance(entry, dict):
continue
if "salt" not in entry or "hash" not in entry:
continue
if secrets.compare_digest(str(entry_id).lower(), request_id):
return self._finish_approval(platform, pending, entry_id, entry)
return None
def list_pending(self, platform: str = None) -> list:
"""List pending pairing requests, optionally filtered by platform.
Codes are stored hashed and are never returned. Each entry exposes a
server-side ``request_id`` that an authenticated admin surface passes
to :meth:`approve_request`. Legacy pre-hash entries have no approvable
id — they report an empty ``request_id`` and age out at TTL.
"""
results = []
with self._lock:
platforms = [platform] if platform else self._all_platforms("pending")
for p in platforms:
self._cleanup_expired(p)
pending = self._load_json(self._pending_path(p))
for entry_id, info in pending.items():
if not isinstance(info, dict):
continue
created_at = info.get("created_at")
if not isinstance(created_at, (int, float)):
continue
age_min = int((time.time() - created_at) / 60)
is_modern = isinstance(info.get("hash"), str) and isinstance(
info.get("salt"), str
)
results.append({
"platform": p,
"request_id": str(entry_id) if is_modern else "",
"user_id": info.get("user_id", ""),
"user_name": info.get("user_name", ""),
"age_minutes": age_min,
})
return results
def clear_pending(self, platform: str = None) -> int:
"""Clear all pending requests. Returns count removed."""
with self._lock:
count = 0
platforms = [platform] if platform else self._all_platforms("pending")
for p in platforms:
pending = self._load_json(self._pending_path(p))
count += len(pending)
self._save_json(self._pending_path(p), {})
return count
# ----- Rate limiting and lockout -----
def _is_rate_limited(self, platform: str, user_id: str) -> bool:
"""Check if a user has requested a code too recently."""
limits = self._load_json(self._rate_limit_path())
for alias in self._user_id_aliases(platform, user_id):
key = f"{platform}:{alias}"
last_request = limits.get(key, 0)
if (time.time() - last_request) < RATE_LIMIT_SECONDS:
return True
return False
def _record_rate_limit(self, platform: str, user_id: str) -> None:
"""Record the time of a pairing request for rate limiting."""
limits = self._load_json(self._rate_limit_path())
now = time.time()
for alias in self._user_id_aliases(platform, user_id):
key = f"{platform}:{alias}"
limits[key] = now
self._save_json(self._rate_limit_path(), limits)
def _is_locked_out(self, platform: str) -> bool:
"""Check if a platform is in lockout due to failed approval attempts."""
limits = self._load_json(self._rate_limit_path())
lockout_key = f"_lockout:{platform}"
lockout_until = limits.get(lockout_key, 0)
return time.time() < lockout_until
def _record_failed_attempt(self, platform: str) -> None:
"""Record a failed approval attempt. Triggers lockout after MAX_FAILED_ATTEMPTS."""
limits = self._load_json(self._rate_limit_path())
fail_key = f"_failures:{platform}"
fails = limits.get(fail_key, 0) + 1
limits[fail_key] = fails
if fails >= MAX_FAILED_ATTEMPTS:
lockout_key = f"_lockout:{platform}"
limits[lockout_key] = time.time() + LOCKOUT_SECONDS
limits[fail_key] = 0 # Reset counter
print(f"[pairing] Platform {platform} locked out for {LOCKOUT_SECONDS}s "
f"after {MAX_FAILED_ATTEMPTS} failed attempts", flush=True)
self._save_json(self._rate_limit_path(), limits)
def _reset_failed_attempts(self, platform: str) -> None:
"""Clear the accumulated failed-approval counter after a success.
Called from the ``approve_code`` success path so that a legitimate
approval resets the brute-force streak (standard lockout semantics:
the counter tracks *consecutive* failures, not lifetime ones).
"""
limits = self._load_json(self._rate_limit_path())
fail_key = f"_failures:{platform}"
if limits.get(fail_key):
limits[fail_key] = 0
self._save_json(self._rate_limit_path(), limits)
# ----- Cleanup -----
def _cleanup_expired(self, platform: str) -> None:
"""Remove expired pending codes.
Tolerant of malformed / legacy entries — anything without a numeric
``created_at`` is treated as expired (it's effectively unusable
with the new hash-keyed schema anyway).
"""
path = self._pending_path(platform)
pending = self._load_json(path)
now = time.time()
expired = []
for entry_id, info in pending.items():
if not isinstance(info, dict):
expired.append(entry_id)
continue
created_at = info.get("created_at")
if not isinstance(created_at, (int, float)):
expired.append(entry_id)
continue
if (now - created_at) > CODE_TTL_SECONDS:
expired.append(entry_id)
if expired:
for entry_id in expired:
del pending[entry_id]
self._save_json(path, pending)
def _all_platforms(self, suffix: str) -> list:
"""List all platforms that have data files of a given suffix."""
platforms = []
for f in self._dir.iterdir():
if f.name.endswith(f"-{suffix}.json"):
platform = f.name.replace(f"-{suffix}.json", "")
if not platform.startswith("_"):
platforms.append(platform)
return platforms
+699
View File
@@ -0,0 +1,699 @@
"""
Platform Adapter Registry
Allows platform adapters (built-in and plugin) to self-register so the gateway
can discover and instantiate them without hardcoded if/elif chains.
Built-in adapters continue to use the existing if/elif in _instantiate_adapter()
for now. Plugin adapters register here via PluginContext.register_platform()
and are looked up first -- if nothing is found the gateway falls through to
the legacy code path. GatewayRunner._create_adapter() wraps both paths and
binds every successful adapter to its runner.
Usage (plugin side):
from gateway.platform_registry import platform_registry, PlatformEntry
platform_registry.register(PlatformEntry(
name="irc",
label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg),
check_fn=check_requirements,
validate_config=lambda cfg: bool(cfg.extra.get("server")),
required_env=["IRC_SERVER"],
install_hint="pip install irc",
))
Usage (gateway side):
adapter = platform_registry.create_adapter("irc", platform_config)
"""
import logging
import sys
import threading
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Optional
from hermes_constants import hermes_home_key
logger = logging.getLogger(__name__)
def _plugin_scope_from_callable(callback: Callable) -> Optional[str]:
"""Infer a plugin profile from code registered outside PluginContext."""
try:
from tools.registry import registry as tool_registry
return tool_registry.plugin_scope_for_callable(callback)
except (ImportError, AttributeError):
return None
def _caller_plugin_scope() -> Optional[str]:
try:
module_name = sys._getframe(2).f_globals.get("__name__", "") or ""
except Exception:
return None
return _plugin_scope_from_callable(
type("_Caller", (), {"__module__": module_name})
)
@dataclass
class PlatformEntry:
"""Metadata and factory for a single platform adapter."""
# Identifier used in config.yaml (e.g. "irc", "viber").
name: str
# Human-readable label (e.g. "IRC", "Viber").
label: str
# Factory callable: receives a PlatformConfig, returns an adapter instance.
# Using a factory instead of a bare class lets plugins do custom init
# (e.g. passing extra kwargs, wrapping in try/except).
adapter_factory: Callable[[Any], Any]
# PASSIVE dependency probe: returns True when the platform's dependencies
# are available RIGHT NOW. Must be side-effect free — it is called from
# status displays (``hermes setup``, ``hermes status``, the dashboard
# readiness probe) and the config enablement pass, none of which may
# trigger a pip install. Put install logic in ``ensure_deps_fn`` instead.
check_fn: Callable[[], bool]
# Optional: given a PlatformConfig, is it properly configured?
# If None, the registry skips config validation and lets the adapter
# fail at connect() time with a descriptive error.
validate_config: Optional[Callable[[Any], bool]] = None
# ACTIVE dependency installer: make the platform's dependencies available,
# installing them (pip / lazy_deps) if needed. Returns True once deps are
# importable, False if they could not be installed. Called by
# ``create_adapter()`` when ``check_fn`` returns False — i.e. exactly at
# the moment the gateway is about to bring the platform up and the user
# has it enabled/configured. None = no auto-install; a False ``check_fn``
# is then a hard block (correct for platforms with no optional deps).
#
# Why two fields (#79812): when the ACTIVE installer was registered as
# ``check_fn``, every status display pip-installed SDKs as a side effect
# (desktop boot-loop at 94%, see gateway/config.py enablement comments);
# when the PASSIVE probe was registered instead, ``create_adapter()``
# returned None before ``connect()`` could lazy-install, so the deps
# never installed at all (Teams deadlock). Splitting the two roles makes
# both call sites correct by construction.
ensure_deps_fn: Optional[Callable[[], bool]] = None
# Optional: given a PlatformConfig, is the platform connected/enabled?
# Used by ``GatewayConfig.get_connected_platforms()`` and setup UI status.
# If None, falls back to ``validate_config`` or ``check_fn``.
is_connected: Optional[Callable[[Any], bool]] = None
# Env vars this platform needs (for ``hermes setup`` display).
required_env: list = field(default_factory=list)
# Hint shown when check_fn returns False.
install_hint: str = ""
# Optional setup function for interactive configuration.
# Signature: () -> None (prompts user, saves env vars).
# If None, falls back to _setup_standard_platform (needs token_var + vars)
# or a generic "set these env vars" display.
setup_fn: Optional[Callable[[], None]] = None
# "builtin" or "plugin"
source: str = "plugin"
# Name of the plugin manifest that registered this entry (empty for
# built-ins). Used by ``hermes gateway setup`` to auto-enable the
# owning plugin when the user configures its platform.
plugin_name: str = ""
# ── Auth env var names (for _is_user_authorized integration) ──
# E.g. "IRC_ALLOWED_USERS" — checked for comma-separated user IDs.
allowed_users_env: str = ""
# E.g. "IRC_ALLOW_ALL_USERS" — if truthy, all users authorized.
allow_all_env: str = ""
# ── Message limits ──
# Max message length for smart-chunking. 0 = no limit.
max_message_length: int = 0
# ── Privacy ──
# If True, session descriptions redact PII (phone numbers, etc.)
pii_safe: bool = False
# ── Display ──
# Emoji for CLI/gateway display (e.g. "💬")
emoji: str = "🔌"
# Whether this platform should appear in _UPDATE_ALLOWED_PLATFORMS
# (allows /update command from this platform).
allow_update_command: bool = True
# ── LLM guidance ──
# Platform hint injected into the system prompt (e.g. "You are on IRC.
# Do not use markdown."). Empty string = no hint.
platform_hint: str = ""
# ── Env-driven auto-configuration ──
# Optional: read env vars, return a dict of ``PlatformConfig.extra`` fields
# to seed when the platform is auto-enabled. Called during
# ``_apply_env_overrides`` BEFORE the adapter is constructed, so
# ``gateway status`` etc. can reflect env-only configuration without
# instantiating the adapter. Return ``None`` (or an empty dict) to skip.
# Signature: () -> Optional[dict[str, Any]]
env_enablement_fn: Optional[Callable[[], Optional[dict]]] = None
# ── YAML→env config bridge ──
# Optional: translate this platform's ``config.yaml`` keys into env vars
# and/or seed ``PlatformConfig.extra`` directly. Lets a plugin own its
# YAML config translation instead of forcing core ``gateway/config.py``
# to know every platform's schema.
#
# Signature: (yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]
# Called from ``load_gateway_config()`` after the generic shared-key loop
# and before ``_apply_env_overrides``. Mutating ``os.environ`` is allowed
# (use ``not os.getenv(...)`` guards to preserve env > YAML precedence);
# any returned dict is merged into ``PlatformConfig.extra``. Exceptions
# are caught and logged at debug level.
# See website/docs/developer-guide/adding-platform-adapters.md for the
# full contract and a worked example.
apply_yaml_config_fn: Optional[Callable[[dict, dict], Optional[dict]]] = None
# Optional: home-channel env var name for cron/notification delivery
# (e.g. ``"IRC_HOME_CHANNEL"``). When set, ``cron.scheduler`` treats this
# platform as a valid ``deliver=<name>`` target and reads the env var to
# resolve the default chat/room ID. Empty = no cron home-channel support.
cron_deliver_env_var: str = ""
# ── Target parsing ──
# Optional: callable that parses a raw target string for this platform into
# a (chat_id, thread_id) tuple, or None if the string is not a recognized
# explicit target. Invoked by ``tools/send_message_tool._parse_target_ref``
# before channel-directory fallback so plugin platforms can declare their
# own native target syntax (e.g. ``fmsg:@alice@example.com``) without
# hard-casing in Hermes core.
#
# Signature:
# (target_ref: str) -> Optional[tuple[str, Optional[str]]]
#
# If the callable returns None the target proceeds to channel-directory
# resolution. No opaque fallback is applied.
parse_target_ref_fn: Optional[Callable[[str], Optional[tuple[str, Optional[str]]]]] = None
# Optional validation applied after parsing/normalization or
# channel-directory resolution. Return True to accept, False to reject, or
# a non-empty string to reject with that diagnostic.
validate_target_ref_fn: Optional[Callable[[str], bool | str]] = None
# Optional whole-request handler for custom platform delivery. Receives
# (args, normalized_chat_id, platform_name, pconfig) and may be sync/async.
# Prefer standalone_sender_fn when the standard send contract is enough.
send_message_handler: Optional[Callable[[dict, str, str, Any], Any]] = None
# ── Standalone (out-of-process) sending ──
# Optional: async coroutine that delivers a message without a live
# gateway adapter. Called by ``tools/send_message_tool._send_via_adapter``
# when ``cron`` runs in a separate process from the gateway and the
# in-process adapter weakref is therefore ``None``.
#
# Signature:
# async (pconfig, chat_id, message, *, thread_id=None,
# media_files=None, force_document=False) -> dict
#
# Returns ``{"success": True, "message_id": ...}`` on success or
# ``{"error": str}`` on failure. Plugin authors typically open an
# ephemeral connection / acquire a fresh OAuth token, send, and close.
# Without this hook, plugin platforms cannot serve as cron ``deliver=``
# targets when the gateway is not co-resident with the cron process.
standalone_sender_fn: Optional[Callable[..., Awaitable[dict]]] = None
class PlatformRegistry:
"""Central registry of platform adapters.
Registrations are serialized, and concurrent lazy lookups share an
in-flight event while the loader runs outside the registry lock.
"""
def __init__(self) -> None:
self._lock = threading.RLock()
# Process-global registrations (for example the built-in relay).
self._entries: dict[str, PlatformEntry] = {}
# Plugin adapters are isolated per resolved HERMES_HOME and overlay the
# process-global entries for lookups in that profile's runtime scope.
self._scoped_entries: dict[str, dict[str, PlatformEntry]] = {}
# Deferred platform loaders: name -> zero-arg callable that imports the
# owning plugin module (which calls register() and populates _entries).
#
# Why this exists: platform adapter modules import heavy, platform-
# specific SDKs at module level (lark_oapi, microsoft_teams, discord.py,
# slack_bolt, ...). Eagerly loading all ~20 bundled platform plugins at
# plugin-discovery time added several seconds to *every* `hermes`
# invocation -- including plain `hermes chat`, which never touches any
# gateway platform. Discovery now registers a cheap deferred loader per
# platform; the real module is imported only when a registry lookup
# actually asks for that platform (gateway start, cron delivery,
# `hermes setup`/`gateway status`, send_message).
self._deferred: dict[str, Callable[[], None]] = {}
self._scoped_deferred: dict[str, dict[str, Callable[[], None]]] = {}
self._inflight: dict[tuple[Optional[str], str], threading.Event] = {}
self._inflight_loaders: dict[
tuple[Optional[str], str], Callable[[], None]
] = {}
self._inflight_owners: dict[tuple[Optional[str], str], int] = {}
self._cancelled_inflight: set[tuple[Optional[str], str]] = set()
# A failed loader is no longer discoverable, but its identity remains
# until ownership teardown can CAS-restore the displaced predecessor.
self._consumed_loaders: dict[
tuple[Optional[str], str], Callable[[], None]
] = {}
@staticmethod
def current_scope_key() -> str:
return hermes_home_key()
def _scope_maps(
self,
scope: Optional[str],
*,
create: bool = False,
) -> tuple[dict[str, PlatformEntry], dict[str, Callable[[], None]]]:
if scope is None:
return self._entries, self._deferred
if create:
return (
self._scoped_entries.setdefault(scope, {}),
self._scoped_deferred.setdefault(scope, {}),
)
return (
self._scoped_entries.get(scope, {}),
self._scoped_deferred.get(scope, {}),
)
# -- deferred loading ----------------------------------------------------
def register_deferred(
self,
name: str,
loader: Callable[[], None],
*,
scope: Optional[str] = None,
) -> None:
"""Register a lazy loader for a platform that hasn't been imported yet.
*loader* is a zero-arg callable that imports the owning plugin module,
which is expected to call :meth:`register` with the real entry for
*name*. The loader runs at most once, the first time *name* is looked
up (or when the full entry list is materialized). A real entry that is
registered directly (e.g. a built-in) takes precedence -- the deferred
loader is then dropped.
"""
with self._lock:
entries, deferred = self._scope_maps(scope, create=True)
self._consumed_loaders.pop((scope, name), None)
if name in entries:
# Already concretely registered; no need to defer.
return
deferred[name] = loader
def snapshot_registration(
self,
name: str,
*,
scope: Optional[str] = None,
) -> tuple[Optional[PlatformEntry], Optional[Callable[[], None]]]:
"""Return the concrete and deferred state for *name* without resolving it.
This host-facing snapshot lets the plugin ledger restore a deferred
platform loader that a concrete registration displaced, without
importing the displaced adapter as a side effect of taking the
snapshot.
"""
with self._lock:
entries, deferred = self._scope_maps(scope)
loader = deferred.get(name)
if entries.get(name) is None and loader is None:
loader = self._inflight_loaders.get((scope, name))
if entries.get(name) is None and loader is None:
loader = self._consumed_loaders.get((scope, name))
return entries.get(name), loader
def restore_registration(
self,
name: str,
current: tuple[Optional[PlatformEntry], Optional[Callable[[], None]]],
previous: tuple[Optional[PlatformEntry], Optional[Callable[[], None]]],
*,
scope: Optional[str] = None,
) -> bool:
"""Restore a platform registration if its full state is still current.
The identity checks protect a later registration from being removed
while still allowing an unloaded override to reveal the registration
it displaced. Both concrete entries and deferred loaders are part of
the state because bundled platform plugins load lazily.
"""
with self._lock:
entries, deferred = self._scope_maps(scope, create=True)
entry = entries.get(name)
loader = deferred.get(name)
load_key = (scope, name)
if entry is None and loader is None:
loader = self._inflight_loaders.get(load_key)
if entry is None and loader is None:
loader = self._consumed_loaders.get(load_key)
current_state = (entry, loader)
is_current = not (
current_state[0] is not current[0]
or current_state[1] is not current[1]
)
if not is_current:
return False
previous_entry, previous_loader = previous
if previous_entry is None:
entries.pop(name, None)
else:
entries[name] = previous_entry
if previous_loader is None:
deferred.pop(name, None)
else:
deferred[name] = previous_loader
if load_key in self._inflight:
self._cancelled_inflight.add(load_key)
self._consumed_loaders.pop(load_key, None)
if scope is not None:
if not entries:
self._scoped_entries.pop(scope, None)
if not deferred:
self._scoped_deferred.pop(scope, None)
return True
def _resolve(self, name: str, scope: Optional[str] = None) -> None:
"""Run the deferred loader for *name* if one is pending."""
loader: Optional[Callable[[], None]] = None
event: Optional[threading.Event] = None
load_key: tuple[Optional[str], str]
is_loader = False
with self._lock:
active_scope = scope or self.current_scope_key()
entries, deferred = self._scope_maps(active_scope)
scoped_key = (active_scope, name)
global_key = (None, name)
event = self._inflight.get(scoped_key)
load_key = scoped_key
if event is None and name not in entries:
loader = deferred.pop(name, None)
if event is None and loader is None and name not in entries:
event = self._inflight.get(global_key)
load_key = global_key
if event is None and loader is None and name not in entries:
loader = self._deferred.pop(name, None)
load_key = global_key
if event is None and loader is not None:
event = threading.Event()
self._inflight[load_key] = event
self._inflight_loaders[load_key] = loader
self._inflight_owners[load_key] = threading.get_ident()
is_loader = True
if event is None:
return
if (
not is_loader
and self._inflight_owners.get(load_key) == threading.get_ident()
):
logger.warning(
"Deferred platform '%s' recursively requested while loading",
name,
)
return
if not is_loader:
event.wait()
# Teardown may have restored an older deferred generation while
# cancelling the one we waited for. Resolve that predecessor in
# the same lookup instead of returning a one-shot false negative.
self._resolve(name, active_scope)
return
try:
loader()
except Exception as e:
logger.warning(
"Deferred load of platform '%s' failed: %s",
name,
e,
exc_info=True,
)
finally:
with self._lock:
was_cancelled = load_key in self._cancelled_inflight
load_scope, _load_name = load_key
entries, deferred = self._scope_maps(load_scope)
if (
not was_cancelled
and name not in entries
and name not in deferred
):
self._consumed_loaders[load_key] = loader
self._inflight.pop(load_key, None)
self._inflight_loaders.pop(load_key, None)
self._inflight_owners.pop(load_key, None)
self._cancelled_inflight.discard(load_key)
event.set()
if was_cancelled:
self._resolve(name, active_scope)
def is_deferred_load_cancelled(
self,
name: str,
*,
scope: Optional[str] = None,
) -> bool:
"""Return whether ownership teardown cancelled an in-flight loader."""
with self._lock:
return (scope, name) in self._cancelled_inflight
def _resolve_all(self) -> None:
"""Run every pending deferred loader.
Used by the iterate-all accessors (``all_entries``/``plugin_entries``),
which are only called by paths that genuinely need every adapter:
gateway startup, ``hermes setup``/``gateway status``, channel
directory. CLI chat never iterates the full set.
"""
active_scope = self.current_scope_key()
with self._lock:
_entries, scoped_deferred = self._scope_maps(active_scope)
scoped_names = set(scoped_deferred)
global_names = set(self._deferred)
for inflight_scope, name in self._inflight:
if inflight_scope == active_scope:
scoped_names.add(name)
elif inflight_scope is None:
global_names.add(name)
# Load outside the registry lock; each name has an in-flight event so
# concurrent readers wait for the same materialization.
for name in sorted(scoped_names):
self._resolve(name, active_scope)
for name in sorted(global_names):
self._resolve(name, active_scope)
def register(
self,
entry: PlatformEntry,
*,
scope: Optional[str] = None,
) -> None:
"""Register a platform adapter entry.
If an entry with the same name exists, it is replaced (last writer
wins -- this lets plugins override built-in adapters if desired).
"""
with self._lock:
if scope is None and entry.source == "plugin":
scope = _caller_plugin_scope()
if scope is None:
scope = _plugin_scope_from_callable(entry.adapter_factory)
if scope is None:
scope = _plugin_scope_from_callable(entry.check_fn)
# A concrete registration supersedes any pending deferred loader.
entries, deferred = self._scope_maps(scope, create=True)
self._consumed_loaders.pop((scope, entry.name), None)
deferred.pop(entry.name, None)
if entry.name in entries:
prev = entries[entry.name]
logger.info(
"Platform '%s' re-registered (was %s, now %s)",
entry.name,
prev.source,
entry.source,
)
entries[entry.name] = entry
logger.debug("Registered platform adapter: %s (%s)", entry.name, entry.source)
def unregister(self, name: str, *, scope: Optional[str] = None) -> bool:
"""Remove a platform entry. Returns True if it existed."""
with self._lock:
inferred_scope = scope if scope is not None else _caller_plugin_scope()
active_scope = inferred_scope or self.current_scope_key()
entries, deferred = self._scope_maps(active_scope)
if inferred_scope is not None or name in entries or name in deferred:
deferred.pop(name, None)
removed = entries.pop(name, None) is not None
if not entries:
self._scoped_entries.pop(active_scope, None)
if not deferred:
self._scoped_deferred.pop(active_scope, None)
return removed
self._deferred.pop(name, None)
return self._entries.pop(name, None) is not None
def get(self, name: str) -> Optional[PlatformEntry]:
"""Look up a platform entry by name."""
scope = self.current_scope_key()
with self._lock:
entries, deferred = self._scope_maps(scope)
needs_resolve = name not in entries and (
name in deferred
or (name not in self._entries and name in self._deferred)
or (scope, name) in self._inflight
or (None, name) in self._inflight
)
if needs_resolve:
self._resolve(name, scope)
with self._lock:
entries, _deferred = self._scope_maps(scope)
return entries.get(name) or self._entries.get(name)
def all_entries(self) -> list[PlatformEntry]:
"""Return all registered platform entries."""
self._resolve_all()
with self._lock:
entries = dict(self._entries)
entries.update(self._scoped_entries.get(self.current_scope_key(), {}))
return list(entries.values())
def plugin_entries(self) -> list[PlatformEntry]:
"""Return only plugin-registered platform entries."""
self._resolve_all()
return [e for e in self.all_entries() if e.source == "plugin"]
def registered_names(self) -> set[str]:
"""Return concrete and deferred platform names without loading adapters.
Mirrors ``is_registered()``'s scope semantics: names registered under
the current profile scope AND process-global names both count. Plugin
platforms register deferred loaders under a profile scope, so reading
only the global maps would miss every plugin platform.
"""
with self._lock:
scope = self.current_scope_key()
entries, deferred = self._scope_maps(scope)
return (
entries.keys()
| deferred.keys()
| self._entries.keys()
| self._deferred.keys()
)
def is_registered(self, name: str) -> bool:
# A deferred (not-yet-imported) platform still counts as registered --
# the loader will materialize it on first real use. This keeps cheap
# membership checks (toolset resolution, webhook deliver-target checks)
# from triggering a heavy import.
with self._lock:
scope = self.current_scope_key()
entries, deferred = self._scope_maps(scope)
return (
name in entries
or name in deferred
or name in self._entries
or name in self._deferred
or (scope, name) in self._inflight
or (None, name) in self._inflight
)
def create_adapter(self, name: str, config: Any) -> Optional[Any]:
"""Create an adapter instance for the given platform name.
Returns None if:
- No entry registered for *name*
- check_fn() returns False and deps can't be installed
(no ensure_deps_fn, or ensure_deps_fn() returned False)
- validate_config() returns False (misconfigured)
- The factory raises an exception
"""
entry = self.get(name)
if entry is None:
return None
deps_ok = False
try:
deps_ok = bool(entry.check_fn())
except Exception as e:
logger.warning(
"Platform '%s' check_fn raised: %s", entry.label, e
)
if not deps_ok and entry.ensure_deps_fn is not None:
# Deps missing but the platform can install them on demand.
# This is the ONE place the active installer runs in the adapter
# path: the platform is enabled+configured and the gateway is
# about to connect it, so an install is what the user wants
# (#79812 — Teams' installer previously lived behind this very
# gate inside connect(), which could never be reached).
logger.info(
"Platform '%s' dependencies missing — attempting install...",
entry.label,
)
try:
deps_ok = bool(entry.ensure_deps_fn())
except Exception as e:
logger.warning(
"Platform '%s' dependency install raised: %s",
entry.label,
e,
)
deps_ok = False
if not deps_ok:
hint = f" ({entry.install_hint})" if entry.install_hint else ""
logger.warning(
"Platform '%s' requirements not met%s",
entry.label,
hint,
)
return None
if entry.validate_config is not None:
try:
if not entry.validate_config(config):
logger.warning(
"Platform '%s' config validation failed",
entry.label,
)
return None
except Exception as e:
logger.warning(
"Platform '%s' config validation error: %s",
entry.label,
e,
)
return None
try:
adapter = entry.adapter_factory(config)
return adapter
except Exception as e:
logger.error(
"Failed to create adapter for platform '%s': %s",
entry.label,
e,
exc_info=True,
)
return None
# Module-level singleton
platform_registry = PlatformRegistry()
+409
View File
@@ -0,0 +1,409 @@
# Adding a New Messaging Platform
There are two ways to add a platform to the Hermes gateway:
## Plugin Path (Recommended for Community/Third-Party)
Create a plugin directory in `~/.hermes/plugins/` (or under `plugins/platforms/`
for bundled plugins) with a `plugin.yaml` and `adapter.py`. The adapter
inherits from `BasePlatformAdapter` and registers via
`ctx.register_platform()` in the `register(ctx)` entry point. This requires
**zero changes to core Hermes code**.
The plugin system automatically handles: adapter creation, config parsing,
user authorization, cron delivery, send_message routing, system prompt hints,
status display, gateway setup, and more.
**Optional hooks cover the edges most adapters need:**
- `env_enablement_fn: () -> Optional[dict]` — seeds `PlatformConfig.extra`
(and an optional `home_channel` dict) from env vars BEFORE the adapter is
constructed. Without this, env-only setups don't surface in
`hermes gateway status` or `get_connected_platforms()` until the SDK
instantiates.
- `apply_yaml_config_fn: (yaml_cfg, platform_cfg) -> Optional[dict]` —
translate this platform's `config.yaml` keys into env vars and/or seed
`PlatformConfig.extra` directly. Lets a plugin own its YAML schema
instead of growing core `gateway/config.py` boilerplate per platform.
Mutating `os.environ` is allowed (use `not os.getenv(...)` guards to
preserve env > YAML precedence); the returned dict is merged into
`PlatformConfig.extra`. Called during `load_gateway_config()` after
the generic shared-key loop and before `_apply_env_overrides()`.
- `cron_deliver_env_var: str` — name of the `*_HOME_CHANNEL` env var. When
set, `deliver=<name>` cron jobs route to this var without editing
`cron/scheduler.py`'s hardcoded sets.
- `standalone_sender_fn: async (...) -> dict`: out-of-process delivery
for cron jobs that run separately from the gateway. Without this, a
`deliver=<name>` job fires correctly but the actual send returns
`No live adapter for platform '<name>'`. Pair with `cron_deliver_env_var`
for end-to-end cron support. See the docsite for the signature.
- `plugin.yaml` `requires_env` / `optional_env` rich-dict entries —
auto-populate `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` so the setup
wizard surfaces proper descriptions, prompts, password flags, and URLs.
**Subclassing for platform-specific UX.** When a platform has a hard
time-window constraint that the base adapter can't anticipate (LINE's
60s single-use reply token, WhatsApp's 24h session window, etc.), an
adapter can override `_keep_typing` to layer a mid-flight bubble at a
threshold without expanding the kwarg surface. Always
`await super()._keep_typing(...)` so the typing heartbeat keeps running,
and tear down your side task in `finally`. See `plugins/platforms/line/`
for the full pattern (Template Buttons postback at 45s, `RequestCache`
state machine, `interrupt_session_activity` override for `/stop`
orphans) and the developer-guide page for the prose walkthrough.
**Sibling adapters that share behavior.** When a single platform has
two transport modes the user picks between — unofficial vs official
APIs, polling vs websocket, library A vs library B — the right
structure is two adapters that share a behavior mixin. WhatsApp does
this: `gateway/platforms/whatsapp.py` (Baileys bridge) and
`gateway/platforms/whatsapp_cloud.py` (Meta Cloud API) both inherit
from `WhatsAppBehaviorMixin` in `gateway/platforms/whatsapp_common.py`.
The mixin owns gating, allow-lists, mention parsing, broadcast
filters, and the WhatsApp-flavored markdown conversion — everything
that's platform-protocol-agnostic. Each adapter owns its transport.
Both register distinct `Platform.*` enum values so the gateway can run
both simultaneously against different phone numbers. The mixin must
come **first** in the bases list — `class WhatsAppAdapter(Mixin,
BasePlatformAdapter)` — so the mixin's `format_message` overrides
`BasePlatformAdapter`'s generic default.
See `plugins/platforms/irc/`, `plugins/platforms/teams/`, and
`plugins/platforms/google_chat/` for complete working examples, and
`website/docs/developer-guide/adding-platform-adapters.md` for the full
plugin guide with code examples and hook documentation.
---
## Built-in Path (Core Contributors Only)
Checklist for integrating a platform directly into the Hermes core.
Use this as a reference when building a built-in adapter — every item here
is a real integration point. Missing any of them will cause broken
functionality, missing features, or inconsistent behavior.
---
## 1. Core Adapter (`gateway/platforms/<platform>.py`)
The adapter is a subclass of `BasePlatformAdapter` from `gateway/platforms/base.py`.
### Required methods
| Method | Purpose |
|--------|---------|
| `__init__(self, config)` | Parse config, init state. Call `super().__init__(config, Platform.YOUR_PLATFORM)` |
| `connect() -> bool` | Connect to the platform, start listeners. Return True on success |
| `disconnect()` | Stop listeners, close connections, cancel tasks |
| `send(chat_id, text, ...) -> SendResult` | Send a text message |
| `send_typing(chat_id)` | Send typing indicator |
| `send_image(chat_id, image_url, caption) -> SendResult` | Send an image |
| `get_chat_info(chat_id) -> dict` | Return `{name, type, chat_id}` for a chat |
### Optional methods (have default stubs in base)
| Method | Purpose |
|--------|---------|
| `send_document(chat_id, path, caption)` | Send a file attachment |
| `send_voice(chat_id, path)` | Send a voice message |
| `send_video(chat_id, path, caption)` | Send a video |
| `send_animation(chat_id, path, caption)` | Send a GIF/animation |
| `send_image_file(chat_id, path, caption)` | Send image from local file |
### Interactive UX (recommended if your platform supports tappable buttons)
If your platform supports interactive button/menu messages, implement these for a more polished agent experience. They all degrade gracefully to plain text when not overridden:
| Method | Purpose |
|--------|---------|
| `send_clarify(chat_id, question, choices, clarify_id, session_key, ...)` | Render the `clarify` tool's multi-choice question as tappable buttons. Pair with inbound dispatch that routes button taps to `tools.clarify_gateway.resolve_gateway_clarify`. |
| `send_exec_approval(chat_id, command, session_key, description, ...)` | Render dangerous-command approval as Approve/Deny buttons. Inbound dispatch routes to `tools.approval.resolve_gateway_approval`. |
| `send_slash_confirm(chat_id, title, message, session_key, confirm_id, ...)` | Render slash-command confirmations (e.g. `/reload-mcp`) as Once/Always/Cancel buttons. Inbound dispatch routes to `tools.slash_confirm.resolve`. |
| `send_model_picker(...)` | Interactive `/model` picker. Used by Telegram and Discord. |
| `send_choice_picker(...)` | Flat single-level picker for finite-choice commands (`/reasoning`, `/fast`). Implemented by Telegram (inline keyboard), Discord (select menu), and Matrix (reactions). Platforms without it fall back to the text status card automatically. |
See `gateway/platforms/telegram.py`, `discord.py`, and `whatsapp_cloud.py` for reference implementations. The button-callback id convention (`cl:<id>:<idx>`, `appr:<id>:<choice>`, `sc:<choice>:<id>`) is shared across adapters — match it so the gateway-side resolvers work without modification.
### Required function
```python
def check_<platform>_requirements() -> bool:
"""Check if this platform's dependencies are available."""
```
### Key patterns to follow
- Use `self.build_source(...)` to construct `SessionSource` objects
- Call `self.handle_message(event)` to dispatch inbound messages to the gateway
- Use `MessageEvent`, `MessageType`, `SendResult` from base
- Use `cache_image_from_bytes`, `cache_audio_from_bytes`, `cache_document_from_bytes` for attachments
- Filter self-messages (prevent reply loops)
- Filter sync/echo messages if the platform has them
- Redact sensitive identifiers (phone numbers, tokens) in all log output
- Implement reconnection with exponential backoff + jitter for streaming connections
- Set `MAX_MESSAGE_LENGTH` if the platform has message size limits
---
## 2. Platform Enum (`gateway/config.py`)
Add the platform to the `Platform` enum:
```python
class Platform(Enum):
...
YOUR_PLATFORM = "your_platform"
```
Add env var loading in `_apply_env_overrides()`:
```python
# Your Platform
your_token = os.getenv("YOUR_PLATFORM_TOKEN")
if your_token:
if Platform.YOUR_PLATFORM not in config.platforms:
config.platforms[Platform.YOUR_PLATFORM] = PlatformConfig()
config.platforms[Platform.YOUR_PLATFORM].enabled = True
config.platforms[Platform.YOUR_PLATFORM].token = your_token
```
Update `get_connected_platforms()` if your platform doesn't use token/api_key
(e.g., WhatsApp uses `enabled` flag, Signal uses `extra` dict).
---
## 3. Adapter Factory (`gateway/run.py`)
Add to `_instantiate_adapter()`:
```python
elif platform == Platform.YOUR_PLATFORM:
from gateway.platforms.your_platform import YourAdapter, check_your_requirements
if not check_your_requirements():
logger.warning("Your Platform: dependencies not met")
return None
return YourAdapter(config)
```
`_create_adapter()` wraps this factory and binds every successful adapter to
its `GatewayRunner`. Do not construct platform adapters in lifecycle call sites;
startup and reconnect must keep using the wrapper so profile routing is wired
before `connect()`.
---
## 4. Authorization Maps (`gateway/run.py`)
Add to BOTH dicts in `_is_user_authorized()`:
```python
platform_env_map = {
...
Platform.YOUR_PLATFORM: "YOUR_PLATFORM_ALLOWED_USERS",
}
platform_allow_all_map = {
...
Platform.YOUR_PLATFORM: "YOUR_PLATFORM_ALLOW_ALL_USERS",
}
```
---
## 5. Session Source (`gateway/session.py`)
If your platform needs extra identity fields (e.g., Signal's UUID alongside
phone number), add them to the `SessionSource` dataclass with `Optional` defaults,
and update `to_dict()`, `from_dict()`, and `build_source()` in base.py.
---
## 6. System Prompt Hints (`agent/prompt_builder.py`)
Add a `PLATFORM_HINTS` entry so the agent knows what platform it's on:
```python
PLATFORM_HINTS = {
...
"your_platform": (
"You are on Your Platform. "
"Describe formatting capabilities, media support, etc."
),
}
```
Without this, the agent won't know it's on your platform and may use
inappropriate formatting (e.g., markdown on platforms that don't render it).
---
## 7. Toolset (`toolsets.py`)
Add a named toolset for your platform:
```python
"hermes-your-platform": {
"description": "Your Platform bot toolset",
"tools": _HERMES_CORE_TOOLS,
"includes": []
},
```
And add it to the `hermes-gateway` composite:
```python
"hermes-gateway": {
"includes": [..., "hermes-your-platform"]
}
```
---
## 8. Cron Delivery (`cron/scheduler.py`)
Add to `platform_map` in `_deliver_result()`:
```python
platform_map = {
...
"your_platform": Platform.YOUR_PLATFORM,
}
```
Without this, `cronjob(action="create", deliver="your_platform", ...)` silently fails.
---
## 9. Send Message Tool (`tools/send_message_tool.py`)
Add to `platform_map` in `send_message_tool()`:
```python
platform_map = {
...
"your_platform": Platform.YOUR_PLATFORM,
}
```
Add routing in `_send_to_platform()`:
```python
elif platform == Platform.YOUR_PLATFORM:
return await _send_your_platform(pconfig, chat_id, message)
```
Implement `_send_your_platform()` — a standalone async function that sends
a single message without requiring the full adapter (for use by cron jobs
and the send_message tool outside the gateway process).
Update the tool schema `target` description to include your platform example.
---
## 10. Cronjob Tool Schema (`tools/cronjob_tools.py`)
Update the `deliver` parameter description and docstring to mention your
platform as a delivery option.
---
## 11. Channel Directory (`gateway/channel_directory.py`)
If your platform can't enumerate chats (most can't), add it to the
session-based discovery list:
```python
for plat_name in ("telegram", "whatsapp", "signal", "your_platform"):
```
---
## 12. Status Display (`hermes_cli/status.py`)
Add to the `platforms` dict in the Messaging Platforms section:
```python
platforms = {
...
"Your Platform": ("YOUR_PLATFORM_TOKEN", "YOUR_PLATFORM_HOME_CHANNEL"),
}
```
---
## 13. Gateway Setup Wizard (`hermes_cli/gateway.py`)
Add to the `_PLATFORMS` list:
```python
{
"key": "your_platform",
"label": "Your Platform",
"emoji": "📱",
"token_var": "YOUR_PLATFORM_TOKEN",
"setup_instructions": [...],
"vars": [...],
}
```
If your platform needs custom setup logic (connectivity testing, QR codes,
policy choices), add a `_setup_your_platform()` function and route to it
in the platform selection switch.
Update `_platform_status()` if your platform's "configured" check differs
from the standard `bool(get_env_value(token_var))`.
---
## 14. Phone/ID Redaction (`agent/redact.py`)
If your platform uses sensitive identifiers (phone numbers, etc.), add a
regex pattern and redaction function to `agent/redact.py`. This ensures
identifiers are masked in ALL log output, not just your adapter's logs.
---
## 15. Documentation
| File | What to update |
|------|---------------|
| `README.md` | Platform list in feature table + documentation table |
| `AGENTS.md` | Gateway description + env var config section |
| `website/docs/user-guide/messaging/<platform>.md` | **NEW** — Full setup guide (see existing platform docs for template) |
| `website/docs/user-guide/messaging/index.md` | Architecture diagram, toolset table, security examples, Next Steps links |
| `website/docs/reference/environment-variables.md` | All env vars for the platform |
---
## 16. Tests (`tests/gateway/test_<platform>.py`)
Recommended test coverage:
- Platform enum exists with correct value
- Config loading from env vars via `_apply_env_overrides`
- Adapter init (config parsing, allowlist handling, default values)
- Helper functions (redaction, parsing, file type detection)
- Session source round-trip (to_dict → from_dict)
- Authorization integration (platform in allowlist maps)
- Send message tool routing (platform in platform_map)
Optional but valuable:
- Async tests for message handling flow (mock the platform API)
- SSE/WebSocket reconnection logic
- Attachment processing
- Group message filtering
---
## Quick Verification
After implementing everything, verify with:
```bash
# All tests pass
python -m pytest tests/ -q
# Grep for your platform name to find any missed integration points
grep -r "telegram\|discord\|whatsapp\|slack" gateway/ tools/ agent/ cron/ hermes_cli/ toolsets.py \
--include="*.py" -l | sort -u
# Check each file in the output — if it mentions other platforms but not yours, you missed it
```
+45
View File
@@ -0,0 +1,45 @@
"""
Platform adapters for messaging integrations.
Each adapter handles:
- Receiving messages from a platform
- Sending messages/responses back
- Platform-specific authentication
- Message formatting and media handling
"""
from .base import BasePlatformAdapter, MessageEvent, SendResult
# QQAdapter and YuanbaoAdapter were previously imported eagerly here, but
# nothing in the codebase consumes ``from gateway.platforms import
# QQAdapter`` (every real call site uses the long-form path
# ``from gateway.platforms.qqbot import QQAdapter``). The eager imports
# pulled in qqbot's chunked-upload + keyboards + onboard machinery and
# yuanbao's websocket stack — about 48 ms wall and ~8 MB RSS on every
# CLI invocation, even ones that never touch a gateway adapter.
#
# Use PEP 562 module ``__getattr__`` to keep the public re-export working
# while deferring the actual import to first attribute access. This is
# 100% backward-compatible for any external code that still imports the
# adapters from the package root.
__all__ = [
"BasePlatformAdapter",
"MessageEvent",
"SendResult",
"QQAdapter",
"YuanbaoAdapter",
]
def __getattr__(name):
if name == "QQAdapter":
from .qqbot import QQAdapter # noqa: F401
return QQAdapter
if name == "YuanbaoAdapter":
from .yuanbao import YuanbaoAdapter # noqa: F401
return YuanbaoAdapter
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
return sorted(__all__)
+84
View File
@@ -0,0 +1,84 @@
"""Shared HTTP client factory for long-lived platform adapters.
Gateway messaging platforms (QQ Bot, Feishu, WeCom, DingTalk, Signal,
BlueBubbles, WeCom-callback) keep a persistent ``httpx.AsyncClient``
alive for the adapter's lifetime. That amortises TLS/connection setup
across many API calls, but it also means the process's file-descriptor
pressure is sensitive to how aggressively the pool recycles idle keep-
alive connections.
httpx's default ``keepalive_expiry`` is 5 seconds. On macOS behind
Cloudflare Warp (and other transparent proxies), peer-initiated FIN can
sit in ``CLOSE_WAIT`` longer than that before the local socket actually
drains — which, multiplied across 7 long-lived adapters plus the LLM
client and MCP clients, walks straight into the default 256 fd limit.
See #18451.
``platform_httpx_limits()`` returns a tighter ``httpx.Limits`` the
adapter factories use instead of the httpx default. The values chosen:
* ``max_keepalive_connections=10`` — plenty for any single adapter;
platform APIs rarely parallelise beyond this.
* ``keepalive_expiry=2.0`` — close idle sockets aggressively so a
proxy's lingering CLOSE_WAIT window can't starve the process.
Override via ``HERMES_GATEWAY_HTTPX_KEEPALIVE_EXPIRY`` /
``HERMES_GATEWAY_HTTPX_MAX_KEEPALIVE`` env vars when tuning under load.
"""
from __future__ import annotations
import os
try:
import httpx
except ImportError: # pragma: no cover — optional dep
httpx = None # type: ignore[assignment]
_DEFAULT_KEEPALIVE_EXPIRY_S = 2.0
_DEFAULT_MAX_KEEPALIVE = 10
def platform_httpx_limits() -> "httpx.Limits | None":
"""Return ``httpx.Limits`` tuned for persistent platform-adapter clients.
Returns ``None`` when httpx isn't importable, so callers can fall
back to httpx's built-in default without a hard dependency on this
helper being reachable.
"""
if httpx is None:
return None
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
val = float(raw)
except (TypeError, ValueError):
return default
return val if val > 0 else default
def _env_int(name: str, default: int) -> int:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
val = int(raw)
except (TypeError, ValueError):
return default
return val if val > 0 else default
keepalive_expiry = _env_float(
"HERMES_GATEWAY_HTTPX_KEEPALIVE_EXPIRY", _DEFAULT_KEEPALIVE_EXPIRY_S
)
max_keepalive = _env_int(
"HERMES_GATEWAY_HTTPX_MAX_KEEPALIVE", _DEFAULT_MAX_KEEPALIVE
)
return httpx.Limits(
max_keepalive_connections=max_keepalive,
# Leave max_connections at httpx default (100) — plenty of headroom.
keepalive_expiry=keepalive_expiry,
)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,186 @@
"""RoomLink dispatch validation and hidden member-session ownership."""
import asyncio
import hashlib
import hmac
import time
from typing import Any
try:
from aiohttp import web
except ImportError:
web = None # type: ignore[assignment]
async def _ensure_hosted_member_session(self, dispatch: Any) -> str:
"""Create or verify the target's canonical hidden group session.
Reusing the ``Group: <room_id>`` namespace is intentional: a room that
moves from Desktop-assisted to hosted execution keeps one transcript.
A conflicting title with a different session id fails closed instead
of merging unrelated conversations.
"""
db = await self._ensure_session_db_async()
if db is None:
raise RuntimeError("session database unavailable")
title = f"Group: {dispatch.room_id}"
seed = (
f"{dispatch.home_install_id}\0{dispatch.room_id}\0"
f"{dispatch.member_id}\0{dispatch.target_profile}"
)
session_id = f"room_{hashlib.sha256(seed.encode()).hexdigest()[:32]}"
def ensure() -> str:
def atomic(conn):
row = conn.execute(
"SELECT id, title, source FROM sessions WHERE id=?",
(session_id,),
).fetchone()
if row is not None:
if row["title"] != title or row["source"] != "bot_room":
raise RuntimeError("room session identity conflicts with existing data")
return session_id
clean_title = db.sanitize_title(title)
conflict = conn.execute(
"SELECT id FROM sessions WHERE title=? AND id!=?",
(clean_title, session_id),
).fetchone()
if conflict:
raise RuntimeError(
"Another group already uses this room title on the target gateway. "
"Rename or migrate that group before retrying."
)
conn.execute(
"INSERT INTO sessions(id, source, title, hidden, started_at) "
"VALUES(?, 'bot_room', ?, 1, ?)",
(session_id, clean_title, time.time()),
)
return session_id
return db._execute_write(atomic)
return await asyncio.to_thread(ensure)
async def _normalize_room_dispatch(
self,
request: "web.Request",
body: Any,
*,
_api_server,
) -> tuple[Any, "web.Response | None"]:
"""Validate and normalize a scoped RoomLink dispatch request."""
_api_request_profile = _api_server._api_request_profile
_openai_error = _api_server._openai_error
room_token = self._room_grant_token(request)
if not room_token:
return body, None
allowed_room_fields = {"input", "hosted_room_dispatch"}
if not isinstance(body, dict) or set(body) - allowed_room_fields:
return body, web.json_response(
_openai_error(
"Room dispatch accepts only input and hosted_room_dispatch.",
code="invalid_room_dispatch",
),
status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import (
GatewayRoomCatalog,
HostedMemberDispatch,
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
catalog_mapping,
verify_room_grant,
)
from gateway.hosted_room_execution_policy import (
RoomExecutionPolicy,
execution_policy_mapping,
)
dispatch = HostedMemberDispatch.from_mapping(
body.get("hosted_room_dispatch")
)
verify_room_grant(
self._room_grant_secret(),
room_token,
dispatch,
permission="dispatch",
)
active_profile = _api_request_profile.get() or "default"
local_install = hosted_rooms.local_authority_gateway_id()
if (
dispatch.target_profile != active_profile
or dispatch.target_install_id != local_install
):
raise ValueError("room dispatch target does not match this profile")
with self._profile_scope(active_profile):
execution_policy = execution_policy_mapping(
target_profile=active_profile
)
catalog = GatewayRoomCatalog.from_mapping(
catalog_mapping(
installation_id=local_install,
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
link_modes=("direct",),
persistent_process=True,
text=True,
attachments=False,
target_profile=active_profile,
execution_policy=execution_policy,
)
)
policy = RoomExecutionPolicy.from_mapping(
catalog.execution_policy.as_mapping()
)
if not hmac.compare_digest(
policy.policy_digest,
dispatch.execution_policy_digest,
):
raise ValueError("room execution policy changed")
if not hmac.compare_digest(
catalog.catalog_digest,
dispatch.capability_digest,
):
raise ValueError("room capability catalog changed")
supplied_input = body.get("input")
if supplied_input not in {None, dispatch.prompt}:
raise ValueError("room dispatch input does not match its prompt")
expected_key = f"room:{dispatch.task_id}:{dispatch.execution_generation}"
if request.headers.get("Idempotency-Key", "").strip() != expected_key:
raise ValueError("room dispatch idempotency key is invalid")
session_id = await self._ensure_hosted_member_session(dispatch)
return {
"input": dispatch.prompt,
"session_id": session_id,
"hosted_room_dispatch": dispatch.as_mapping(),
"_room_execution_policy": policy.as_mapping(),
}, None
except Exception as exc:
message = str(exc)
lowered = message.lower()
policy_changed = (
"execution policy" in lowered
or "remote room execution requires" in lowered
)
return body, web.json_response(
_openai_error(
(
"Room execution policy changed; reauthorization is required."
if policy_changed
else "Room capability catalog changed; reauthorization is required."
if "capability catalog changed" in lowered
else message
),
code=(
"room_execution_policy_changed"
if policy_changed
else "room_capability_catalog_changed"
if "capability catalog changed" in lowered
else "invalid_room_dispatch"
),
),
status=403,
)
+423
View File
@@ -0,0 +1,423 @@
"""RoomLink room-member grants and capability HTTP handlers."""
import time
import uuid
from typing import Any
try:
from aiohttp import web
except ImportError:
web = None # type: ignore[assignment]
class RoomGrantReauthorizationRequired(ValueError):
"""A validly signed room grant was revoked or superseded."""
def _require_unchanged_execution_policy(
claims: dict[str, Any],
execution_policy: dict[str, Any],
) -> None:
"""Keep renewal from silently granting a changed execution policy."""
if (
str(execution_policy.get("policy_digest") or "")
!= str(claims.get("execution_policy_digest") or "")
):
raise RoomGrantReauthorizationRequired(
"room execution policy changed"
)
def _room_grant_error_response(exc: Exception, *, _openai_error) -> "web.Response":
reauthorization = isinstance(exc, RoomGrantReauthorizationRequired)
return web.json_response(
_openai_error(
(
"Room authorization needs to be renewed."
if reauthorization
else "Room authorization is invalid or expired."
),
err_type="gateway_auth_error",
code=(
"room_reauthorization_required"
if reauthorization
else "invalid_room_grant"
),
),
status=403 if reauthorization else 401,
)
def _http_routes(self) -> list[tuple[str, str, Any]]:
return [
(
"POST",
"/v1/room-members/invitations",
self._handle_room_member_invitation,
),
(
"GET",
"/v1/room-members/capabilities",
self._handle_room_member_capabilities,
),
(
"POST",
"/v1/room-members/grants/refresh",
self._handle_room_member_grant_refresh,
),
(
"POST",
"/v1/room-members/grants/revoke",
self._handle_room_member_grant_revoke,
),
]
def _room_grant_token(request: "web.Request") -> str:
authorization = str(request.headers.get("Authorization") or "")
scheme, separator, token = authorization.partition(" ")
if not separator or scheme.lower() != "hermesroom":
return ""
return token.strip()
def _room_grant_secret(self) -> bytes:
from gateway.hosted_room_peer import gateway_room_grant_secret
return gateway_room_grant_secret()
def _room_grant_claims(
self,
request: "web.Request",
*,
permission: str,
) -> dict[str, Any]:
from gateway.hosted_room_peer import decode_room_grant
token = self._room_grant_token(request)
if not token:
raise ValueError("room grant is missing")
claims = decode_room_grant(
self._room_grant_secret(),
token,
permission=permission,
)
from gateway import hosted_rooms
if hosted_rooms.room_grant_is_revoked(
hosted_rooms.default_db_path(),
claims=claims,
):
raise RoomGrantReauthorizationRequired("room grant is revoked")
if not hosted_rooms.peer_room_grant_is_current(
hosted_rooms.default_db_path(),
claims=claims,
):
raise RoomGrantReauthorizationRequired("room grant is no longer current")
return claims
async def _handle_room_member_invitation(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Mint a short-lived room/profile grant for a trusted home gateway."""
auth_err = self._check_auth(request)
if auth_err:
return auth_err
body, error = await self._read_json_body(request)
if error:
return error
required = {
"room_id",
"home_install_id",
"authority_gateway_id",
"authority_epoch",
"member_id",
}
allowed = required | {"grant_id", "ttl_seconds", "status_ttl_seconds"}
if set(body) - allowed or not required <= set(body):
return web.json_response(
_openai_error(
"Invitation is missing required room authority fields.",
code="invalid_room_invitation",
),
status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import (
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
catalog_mapping,
decode_room_grant,
issue_room_grant,
)
from gateway.hosted_room_execution_policy import execution_policy_mapping
profile = _api_request_profile.get() or "default"
target_install_id = hosted_rooms.local_authority_gateway_id()
ttl = float(body.get("ttl_seconds", 3600))
if not 60 <= ttl <= 24 * 60 * 60:
raise ValueError("ttl_seconds must be between 60 and 86400")
status_ttl = float(body.get("status_ttl_seconds", ttl))
if not ttl <= status_ttl <= 30 * 24 * 60 * 60:
raise ValueError(
"status_ttl_seconds must be at least ttl_seconds and no more than 2592000"
)
with self._profile_scope(profile):
execution_policy = execution_policy_mapping(target_profile=profile)
catalog = catalog_mapping(
installation_id=target_install_id,
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
link_modes=("direct",),
persistent_process=True,
text=True,
attachments=False,
target_profile=profile,
execution_policy=execution_policy,
)
token = issue_room_grant(
self._room_grant_secret(),
grant_id=str(body.get("grant_id") or f"grant-{uuid.uuid4().hex}"),
room_id=str(body["room_id"]),
home_install_id=str(body["home_install_id"]),
authority_gateway_id=str(body["authority_gateway_id"]),
authority_epoch=int(body["authority_epoch"]),
member_id=str(body["member_id"]),
target_install_id=target_install_id,
target_profile=profile,
execution_policy_digest=execution_policy["policy_digest"],
issued_at=time.time(),
ttl_seconds=ttl,
status_ttl_seconds=status_ttl,
)
claims = decode_room_grant(
self._room_grant_secret(), token, permission="status"
)
hosted_rooms.reserve_peer_room(
hosted_rooms.default_db_path(),
claims=claims,
expires_at=float(claims.get("status_expires_at", claims["expires_at"])),
)
except Exception as exc:
return web.json_response(
_openai_error(str(exc), code="invalid_room_invitation"),
status=400,
)
return web.json_response(
{
"object": "hermes.room_member.invitation",
"grant": token,
"target_profile": profile,
"catalog": catalog,
"expires_at": float(claims["expires_at"]),
"status_expires_at": float(claims["status_expires_at"]),
},
status=201,
)
async def _handle_room_member_capabilities(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Verify a scoped grant and return this target's live room catalog."""
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import (
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
catalog_mapping,
)
from gateway.hosted_room_execution_policy import execution_policy_mapping
claims = self._room_grant_claims(request, permission="status")
profile = _api_request_profile.get() or "default"
installation_id = hosted_rooms.local_authority_gateway_id()
if (
claims["target_profile"] != profile
or claims["target_install_id"] != installation_id
):
raise ValueError("room grant target does not match this profile")
with self._profile_scope(profile):
execution_policy = execution_policy_mapping(target_profile=profile)
catalog = catalog_mapping(
installation_id=installation_id,
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
link_modes=("direct",),
persistent_process=True,
text=True,
attachments=False,
target_profile=profile,
execution_policy=execution_policy,
)
except Exception as exc:
return _room_grant_error_response(exc, _openai_error=_openai_error)
return web.json_response(
{
"object": "hermes.room_member.capabilities",
"room_id": claims["room_id"],
"home_install_id": claims["home_install_id"],
"authority_gateway_id": claims["authority_gateway_id"],
"authority_epoch": claims["authority_epoch"],
"member_id": claims["member_id"],
"target_profile": profile,
"catalog": catalog,
}
)
async def _handle_room_member_grant_refresh(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Refresh dispatch access without a Desktop or broad gateway key."""
body, error = await self._read_json_body(request)
if error:
return error
if set(body) - {"ttl_seconds"}:
return web.json_response(
_openai_error(
"Grant refresh accepts only ttl_seconds.",
code="invalid_room_grant_refresh",
),
status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import (
MAX_DISPATCH_GRANT_TTL_SECONDS,
issue_room_grant,
)
from gateway.hosted_room_execution_policy import execution_policy_mapping
# A status-only bearer may observe a run but must never mint new
# dispatch authority. Renewal is possible only while the existing
# dispatch permission is still live.
claims = self._room_grant_claims(request, permission="dispatch")
profile = _api_request_profile.get() or "default"
installation_id = hosted_rooms.local_authority_gateway_id()
if (
claims["target_profile"] != profile
or claims["target_install_id"] != installation_id
):
raise ValueError("room grant target does not match this profile")
now = time.time()
hard_expiry = float(
claims.get("status_expires_at", claims["expires_at"])
)
remaining = hard_expiry - now
requested = float(
body.get("ttl_seconds", MAX_DISPATCH_GRANT_TTL_SECONDS)
)
if remaining <= 0 or requested <= 0:
raise ValueError("room grant renewal horizon expired")
dispatch_ttl = min(
requested,
MAX_DISPATCH_GRANT_TTL_SECONDS,
remaining,
)
with self._profile_scope(profile):
execution_policy = execution_policy_mapping(target_profile=profile)
_require_unchanged_execution_policy(claims, execution_policy)
token = issue_room_grant(
self._room_grant_secret(),
grant_id=f"grant-refresh-{uuid.uuid4().hex}",
room_id=claims["room_id"],
home_install_id=claims["home_install_id"],
authority_gateway_id=claims["authority_gateway_id"],
authority_epoch=int(claims["authority_epoch"]),
member_id=claims["member_id"],
target_install_id=installation_id,
target_profile=profile,
execution_policy_digest=execution_policy["policy_digest"],
permissions=claims["permissions"],
issued_at=now,
ttl_seconds=dispatch_ttl,
status_expires_at=hard_expiry,
)
except Exception as exc:
return _room_grant_error_response(exc, _openai_error=_openai_error)
return web.json_response(
{
"object": "hermes.room_member.grant",
"grant": token,
"expires_at": now + dispatch_ttl,
"status_expires_at": hard_expiry,
"execution_policy": execution_policy,
}
)
async def _handle_room_member_grant_revoke(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Revoke exactly the scoped grant authenticating this request."""
body, error = await self._read_json_body(request)
if error:
return error
if body:
return web.json_response(
_openai_error(
"Grant revoke accepts no fields.",
code="invalid_room_grant_revoke",
),
status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import decode_room_grant
token = self._room_grant_token(request)
if not token:
raise ValueError("room grant is missing")
# Revoke is idempotent: a response-lost retry may authenticate with
# the grant that was just added to the denylist. Verify signature,
# scope, and hard horizon directly, then upsert the same grant id.
claims = decode_room_grant(
self._room_grant_secret(),
token,
permission="status",
)
profile = _api_request_profile.get() or "default"
installation_id = hosted_rooms.local_authority_gateway_id()
if (
claims["target_profile"] != profile
or claims["target_install_id"] != installation_id
):
raise ValueError("room grant target does not match this profile")
hosted_rooms.revoke_room_grant_scope(
hosted_rooms.default_db_path(),
claims=claims,
expires_at=float(
claims.get("status_expires_at", claims["expires_at"])
),
)
except Exception:
return web.json_response(
_openai_error(
"Room authorization is invalid or expired.",
err_type="gateway_auth_error",
code="invalid_room_grant",
),
status=401,
)
return web.json_response(
{
"object": "hermes.room_member.grant.revocation",
"revoked": True,
}
)
@@ -0,0 +1,376 @@
"""Durable idempotency reservations for API server runs."""
import hmac
import json
import logging
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict
# Keep the extracted store's log records on the API server logger.
logger = logging.getLogger("gateway.platforms.api_server")
class RunIdempotencyStore:
"""Durable, tenant-scoped reservations for ``POST /v1/runs``.
A unique ``(scope, key)`` row is inserted inside ``BEGIN IMMEDIATE`` so
separate gateway workers/processes cannot both admit the same request.
Only request fingerprints and public run status are stored; request bodies
and credentials are deliberately excluded.
"""
RETENTION_SECONDS = 24 * 60 * 60
ACKNOWLEDGED_RETENTION_SECONDS = 24 * 60 * 60
@property
def durable(self) -> bool:
"""Whether reservations survive this process."""
return self._db_path is not None
def __init__(self, db_path: str = None):
if db_path is None:
try:
from hermes_cli.config import get_hermes_home
db_path = str(get_hermes_home() / "runs_idempotency.db")
except Exception:
db_path = ":memory:"
self._db_path = None if db_path == ":memory:" else db_path
try:
self._conn = sqlite3.connect(db_path, check_same_thread=False, timeout=30)
except Exception as exc:
logger.warning(
"Run idempotency storage is unavailable; falling back to "
"process memory, so replay will not survive a restart: %s",
exc,
)
self._conn = sqlite3.connect(":memory:", check_same_thread=False)
self._db_path = None
from hermes_state import apply_wal_with_fallback
apply_wal_with_fallback(self._conn, db_label="runs_idempotency.db")
self._conn.execute(
"""CREATE TABLE IF NOT EXISTS run_idempotency (
scope TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
status_json TEXT NOT NULL,
owner_pid INTEGER NOT NULL DEFAULT 0,
owner_started INTEGER NOT NULL DEFAULT 0,
retention_until REAL NOT NULL DEFAULT 0,
acknowledged_at REAL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY (scope, idempotency_key)
)"""
)
columns = {
str(row[1])
for row in self._conn.execute("PRAGMA table_info(run_idempotency)")
}
if "owner_pid" not in columns:
self._conn.execute(
"ALTER TABLE run_idempotency ADD COLUMN owner_pid INTEGER NOT NULL DEFAULT 0"
)
if "owner_started" not in columns:
self._conn.execute(
"ALTER TABLE run_idempotency ADD COLUMN owner_started INTEGER NOT NULL DEFAULT 0"
)
if "retention_until" not in columns:
self._conn.execute(
"ALTER TABLE run_idempotency ADD COLUMN "
"retention_until REAL NOT NULL DEFAULT 0"
)
if "acknowledged_at" not in columns:
self._conn.execute(
"ALTER TABLE run_idempotency ADD COLUMN acknowledged_at REAL"
)
self._conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS run_idempotency_run_id ON run_idempotency(run_id)"
)
self._conn.commit()
self._lock = threading.Lock()
self._tighten_permissions()
def _tighten_permissions(self) -> None:
if not self._db_path:
return
for candidate in (
Path(self._db_path),
Path(self._db_path + "-wal"),
Path(self._db_path + "-shm"),
):
try:
if candidate.exists():
candidate.chmod(0o600)
except OSError:
logger.debug(
"Failed to restrict run idempotency store permissions",
exc_info=True,
)
def reserve(
self,
scope: str,
key: str,
fingerprint: str,
run_id: str,
status: Dict[str, Any],
*,
owner_pid: int = 0,
owner_started: int = 0,
retention_until: float = 0,
):
"""Atomically reserve a key; return ``(outcome, stored_record)``."""
now = time.time()
retention_until = max(0.0, float(retention_until or 0))
encoded = json.dumps(status, sort_keys=True, separators=(",", ":"))
with self._lock:
self._conn.execute("BEGIN IMMEDIATE")
try:
self._prune_stale_terminal_locked(now)
row = self._conn.execute(
"SELECT fingerprint, run_id, status_json, owner_pid, owner_started, updated_at "
"FROM run_idempotency WHERE scope=? AND idempotency_key=?",
(scope, key),
).fetchone()
if row is not None:
if retention_until:
self._conn.execute(
"""UPDATE run_idempotency
SET retention_until=MAX(retention_until, ?)
WHERE scope=? AND idempotency_key=?
AND fingerprint=?""",
(retention_until, scope, key, fingerprint),
)
self._conn.commit()
outcome = (
"reused"
if hmac.compare_digest(row[0], fingerprint)
else "conflict"
)
return outcome, {
"run_id": row[1],
"status": json.loads(row[2]),
"owner_pid": int(row[3] or 0),
"owner_started": int(row[4] or 0),
"updated_at": float(row[5] or 0),
}
self._conn.execute(
"INSERT INTO run_idempotency("
"scope,idempotency_key,fingerprint,run_id,status_json,"
"owner_pid,owner_started,retention_until,created_at,updated_at"
") VALUES(?,?,?,?,?,?,?,?,?,?)",
(
scope,
key,
fingerprint,
run_id,
encoded,
int(owner_pid or 0),
int(owner_started or 0),
retention_until,
now,
now,
),
)
self._conn.commit()
return "created", {
"run_id": run_id,
"status": status,
"owner_pid": int(owner_pid or 0),
"owner_started": int(owner_started or 0),
"updated_at": now,
}
except Exception:
self._conn.rollback()
raise
def lookup(
self,
scope: str,
key: str,
fingerprint: str,
*,
retention_until: float = 0,
):
"""Return ``missing``, ``reused`` or ``conflict`` without reserving."""
now = time.time()
retention_until = max(0.0, float(retention_until or 0))
with self._lock:
self._conn.execute("BEGIN IMMEDIATE")
try:
if retention_until:
self._conn.execute(
"""UPDATE run_idempotency
SET retention_until=MAX(retention_until, ?)
WHERE scope=? AND idempotency_key=?
AND fingerprint=?""",
(retention_until, scope, key, fingerprint),
)
self._prune_stale_terminal_locked(now)
row = self._conn.execute(
"SELECT fingerprint, run_id, status_json, owner_pid, owner_started, updated_at "
"FROM run_idempotency WHERE scope=? AND idempotency_key=?",
(scope, key),
).fetchone()
self._conn.commit()
except Exception:
self._conn.rollback()
raise
if row is None:
return "missing", None
outcome = "reused" if hmac.compare_digest(row[0], fingerprint) else "conflict"
return outcome, {
"run_id": row[1],
"status": json.loads(row[2]),
"owner_pid": int(row[3] or 0),
"owner_started": int(row[4] or 0),
"updated_at": float(row[5] or 0),
}
def _prune_stale_terminal_locked(self, now: float) -> None:
"""Prune replay records only after their stored run is terminal.
The caller owns ``self._lock`` and an active transaction. Age alone
can never release an in-flight idempotency reservation: a long or
disconnected room turn may legitimately outlive the retention window.
"""
stale = self._conn.execute(
"""SELECT scope, idempotency_key, status_json, retention_until,
acknowledged_at, updated_at
FROM run_idempotency
WHERE acknowledged_at <= ?
OR (retention_until > 0 AND retention_until <= ?)
OR (retention_until <= 0 AND updated_at < ?)""",
(
now - self.ACKNOWLEDGED_RETENTION_SECONDS,
now,
now - self.RETENTION_SECONDS,
),
).fetchall()
for (
stale_scope,
stale_key,
stale_status,
retention_until,
acknowledged_at,
updated_at,
) in stale:
try:
terminal = json.loads(stale_status).get("status") in {
"completed",
"failed",
"cancelled",
"interrupted",
}
except Exception:
terminal = False
expired = bool(
(
acknowledged_at is not None
and float(acknowledged_at)
<= now - self.ACKNOWLEDGED_RETENTION_SECONDS
)
or (
float(retention_until or 0) > 0
and now >= float(retention_until)
)
or (
float(retention_until or 0) <= 0
and float(updated_at or 0) < now - self.RETENTION_SECONDS
)
)
if terminal and expired:
self._conn.execute(
"""DELETE FROM run_idempotency
WHERE scope=? AND idempotency_key=?""",
(stale_scope, stale_key),
)
def status_for_run(
self,
scope: str,
run_id: str,
*,
retention_until: float = 0,
) -> dict[str, Any] | None:
"""Load one durable run status inside its authenticated scope."""
retention_until = max(0.0, float(retention_until or 0))
with self._lock:
if retention_until:
self._conn.execute(
"""UPDATE run_idempotency
SET retention_until=MAX(retention_until, ?)
WHERE scope=? AND run_id=?""",
(retention_until, scope, run_id),
)
self._conn.commit()
row = self._conn.execute(
"SELECT status_json, owner_pid, owner_started, updated_at "
"FROM run_idempotency WHERE scope=? AND run_id=?",
(scope, run_id),
).fetchone()
if row is None:
return None
return {
"status": json.loads(row[0]),
"owner_pid": int(row[1] or 0),
"owner_started": int(row[2] or 0),
"updated_at": float(row[3] or 0),
}
def acknowledge_terminal(self, scope: str, run_id: str) -> bool:
"""Allow cleanup once the room home durably imported terminal output."""
now = time.time()
with self._lock:
changed = self._conn.execute(
"""UPDATE run_idempotency SET acknowledged_at=?
WHERE scope=? AND run_id=?""",
(now, scope, run_id),
).rowcount
self._conn.commit()
return changed == 1
def extend_retention(self, scope: str, run_id: str, until: float) -> bool:
"""Persist the latest verified recovery horizon for an active grant."""
checked_until = max(0.0, float(until or 0))
if not checked_until:
return False
with self._lock:
changed = self._conn.execute(
"""UPDATE run_idempotency
SET retention_until=MAX(retention_until, ?)
WHERE scope=? AND run_id=?""",
(checked_until, scope, run_id),
).rowcount
self._conn.commit()
return changed == 1
def owns_run(self, scope: str, run_id: str) -> bool:
with self._lock:
return (
self._conn.execute(
"SELECT 1 FROM run_idempotency WHERE scope=? AND run_id=?",
(scope, run_id),
).fetchone()
is not None
)
def update_status(self, run_id: str, status: Dict[str, Any]) -> None:
encoded = json.dumps(status, sort_keys=True, separators=(",", ":"))
with self._lock:
self._conn.execute(
"UPDATE run_idempotency SET status_json=?, updated_at=? WHERE run_id=?",
(encoded, time.time(), run_id),
)
self._conn.commit()
def close(self) -> None:
with self._lock:
self._conn.close()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+942
View File
@@ -0,0 +1,942 @@
"""Shared helper classes for gateway platform adapters.
Extracts common patterns that were duplicated across 5-7 adapters:
message deduplication, text batch aggregation, markdown stripping,
and thread participation tracking.
"""
import asyncio
import json
import logging
import re
import time
from pathlib import Path
from typing import TYPE_CHECKING, Dict
from utils import atomic_json_write
if TYPE_CHECKING:
from gateway.platforms.base import MessageEvent
logger = logging.getLogger(__name__)
# ─── Message Deduplication ────────────────────────────────────────────────────
class MessageDeduplicator:
"""TTL-based message deduplication cache.
Replaces the identical ``_seen_messages`` / ``_is_duplicate()`` pattern
previously duplicated in discord, slack, dingtalk, wecom, weixin,
mattermost, and feishu adapters.
Usage::
self._dedup = MessageDeduplicator()
# In message handler:
if self._dedup.is_duplicate(msg_id):
return
"""
def __init__(self, max_size: int = 2000, ttl_seconds: float = 300):
self._seen: Dict[str, float] = {}
self._max_size = max_size
self._ttl = ttl_seconds
def is_duplicate(self, msg_id: str) -> bool:
"""Return True if *msg_id* was already seen within the TTL window."""
if not msg_id:
return False
now = time.time()
if msg_id in self._seen:
if now - self._seen[msg_id] < self._ttl:
return True
# Entry has expired — remove it and treat as new
del self._seen[msg_id]
self._seen[msg_id] = now
if len(self._seen) > self._max_size:
cutoff = now - self._ttl
self._seen = {k: v for k, v in self._seen.items() if v > cutoff}
if len(self._seen) > self._max_size:
# TTL pruning alone does not cap the cache when every entry is
# still fresh. Keep the newest entries so the helper's
# max_size bound is enforced under sustained traffic.
newest = sorted(
self._seen.items(),
key=lambda item: item[1],
)[-self._max_size:]
self._seen = dict(newest)
return False
def contains(self, msg_id: str) -> bool:
"""Return whether *msg_id* is live in the cache without inserting it."""
if not msg_id:
return False
seen_at = self._seen.get(msg_id)
if seen_at is None:
return False
if time.time() - seen_at < self._ttl:
return True
del self._seen[msg_id]
return False
def discard(self, msg_id: str) -> None:
"""Release a claimed message ID after cancelled/failed handoff."""
self._seen.pop(msg_id, None)
def clear(self):
"""Clear all tracked messages."""
self._seen.clear()
# ─── Text Batch Aggregation ──────────────────────────────────────────────────
class TextBatchAggregator:
"""Aggregates rapid-fire text events into single messages.
Replaces the ``_enqueue_text_event`` / ``_flush_text_batch`` pattern
previously duplicated in telegram, discord, matrix, wecom, and feishu.
Usage::
self._text_batcher = TextBatchAggregator(
handler=self._message_handler,
batch_delay=0.6,
split_threshold=1900,
)
# In message dispatch:
if msg_type == MessageType.TEXT and self._text_batcher.is_enabled():
self._text_batcher.enqueue(event, session_key)
return
"""
def __init__(
self,
handler,
*,
batch_delay: float = 0.6,
split_delay: float = 2.0,
split_threshold: int = 4000,
):
self._handler = handler
self._batch_delay = batch_delay
self._split_delay = split_delay
self._split_threshold = split_threshold
self._pending: Dict[str, "MessageEvent"] = {}
self._pending_tasks: Dict[str, asyncio.Task] = {}
def is_enabled(self) -> bool:
"""Return True if batching is active (delay > 0)."""
return self._batch_delay > 0
def enqueue(self, event: "MessageEvent", key: str) -> None:
"""Add *event* to the pending batch for *key*."""
chunk_len = len(event.text or "")
existing = self._pending.get(key)
if not existing:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending[key] = event
else:
existing.text = f"{existing.text}\n{event.text}"
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
# Cancel prior flush timer, start a new one
prior = self._pending_tasks.get(key)
if prior and not prior.done():
prior.cancel()
self._pending_tasks[key] = asyncio.create_task(self._flush(key))
async def _flush(self, key: str) -> None:
"""Wait then dispatch the batched event for *key*."""
current_task = self._pending_tasks.get(key)
pending = self._pending.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
# Use longer delay when the last chunk looks like a split message
delay = self._split_delay if last_len >= self._split_threshold else self._batch_delay
await asyncio.sleep(delay)
event = self._pending.pop(key, None)
if event:
try:
await self._handler(event)
except Exception:
logger.exception("[TextBatchAggregator] Error dispatching batched event for %s", key)
if self._pending_tasks.get(key) is current_task:
self._pending_tasks.pop(key, None)
def cancel_all(self) -> None:
"""Cancel all pending flush tasks."""
for task in self._pending_tasks.values():
if not task.done():
task.cancel()
self._pending_tasks.clear()
self._pending.clear()
# ─── Markdown Stripping ──────────────────────────────────────────────────────
# Pre-compiled regexes for performance
_RE_BOLD = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
_RE_ITALIC_STAR = re.compile(r"\*(.+?)\*", re.DOTALL)
_RE_BOLD_UNDER = re.compile(r"\b__(?![\s_])(.+?)(?<![\s_])__\b", re.DOTALL)
_RE_ITALIC_UNDER = re.compile(r"\b_(?![\s_])(.+?)(?<![\s_])_\b", re.DOTALL)
_RE_CODE_BLOCK = re.compile(r"```[a-zA-Z0-9_+-]*\n?")
_RE_INLINE_CODE = re.compile(r"`(.+?)`")
_RE_HEADING = re.compile(r"^#{1,6}\s+", re.MULTILINE)
_RE_LINK = re.compile(r"\[([^\]]+)\]\([^\)]+\)")
_RE_MULTI_NEWLINE = re.compile(r"\n{3,}")
def strip_markdown(text: str) -> str:
"""Strip markdown formatting for plain-text platforms (SMS, iMessage, etc.).
Replaces the identical ``_strip_markdown()`` functions previously
duplicated in sms.py, bluebubbles.py, and feishu.py.
"""
text = _RE_BOLD.sub(r"\1", text)
text = _RE_ITALIC_STAR.sub(r"\1", text)
text = _RE_BOLD_UNDER.sub(r"\1", text)
text = _RE_ITALIC_UNDER.sub(r"\1", text)
text = _RE_CODE_BLOCK.sub("", text)
text = _RE_INLINE_CODE.sub(r"\1", text)
text = _RE_HEADING.sub("", text)
text = _RE_LINK.sub(r"\1", text)
text = _RE_MULTI_NEWLINE.sub("\n\n", text)
return text.strip()
# ─── Thread Participation Tracking ───────────────────────────────────────────
class ThreadParticipationTracker:
"""Persistent tracking of threads the bot has participated in.
Replaces the identical ``_load/_save_participated_threads`` +
``_mark_thread_participated`` pattern previously duplicated in
discord.py and matrix.py.
Usage::
self._threads = ThreadParticipationTracker("discord")
# Check membership:
if thread_id in self._threads:
...
# Mark participation:
self._threads.mark(thread_id)
"""
_MAX_TRACKED = 500
def __init__(self, platform_name: str, max_tracked: int = 500):
self._platform = platform_name
self._max_tracked = max_tracked
self._threads: dict[str, None] = {
str(thread_id): None for thread_id in self._load()
}
def _state_path(self) -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / f"{self._platform}_threads.json"
def _load(self) -> list[str]:
path = self._state_path()
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, list):
return [str(thread_id) for thread_id in data]
except Exception:
pass
return []
def _save(self) -> None:
path = self._state_path()
thread_list = list(self._threads)
if len(thread_list) > self._max_tracked:
thread_list = thread_list[-self._max_tracked:]
self._threads = dict.fromkeys(thread_list)
atomic_json_write(path, thread_list, indent=None)
def mark(self, thread_id: str) -> None:
"""Mark *thread_id* as participated and persist."""
if thread_id not in self._threads:
self._threads[thread_id] = None
self._save()
def __contains__(self, thread_id: str) -> bool:
return thread_id in self._threads
def clear(self) -> None:
self._threads.clear()
# ─── Phone Number Redaction ──────────────────────────────────────────────────
def redact_phone(phone: str) -> str:
"""Redact a phone number for logging, preserving country code and last 4.
Replaces the identical ``_redact_phone()`` functions in signal.py,
sms.py, and bluebubbles.py.
"""
if not phone:
return "<none>"
if len(phone) <= 8:
return phone[:2] + "****" + phone[-2:] if len(phone) > 4 else "****"
return phone[:4] + "****" + phone[-4:]
# ─── GFM Markdown Table → Bullet Conversion ─────────────────────────────────
# Shared by Discord and Telegram adapters. Discord calls
# convert_table_to_bullets() directly; Telegram imports the primitives
# but keeps its own MarkdownV2-aware renderer.
# Matches a GFM table delimiter row: optional outer pipes, cells of dashes
# (with optional alignment colons) separated by '|'.
# Requires at least one internal '|' so lone '---' rules are NOT matched.
TABLE_SEPARATOR_RE = re.compile(
r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$'
)
def is_table_row(line: str) -> bool:
"""Return True if *line* could plausibly be a table data row."""
stripped = line.strip()
return bool(stripped) and '|' in stripped
def split_markdown_table_row(line: str) -> list[str]:
"""Split a GFM table row into stripped cell values.
Thin delegate to the canonical implementation in
:mod:`agent.markdown_tables` (``split_table_row``) so the three
formerly byte-identical copies (here, ``agent/markdown_tables.py``,
``weixin._split_table_row``) share one body.
"""
from agent.markdown_tables import split_table_row
return split_table_row(line)
def _render_table_block(table_block: list[str]) -> str:
"""Render a detected GFM table as bold-heading + bullet groups.
Uses the same alignment logic as Telegram's renderer: for non-row-label
tables, ``data_cells = cells`` (the full row) and the bullet whose value
duplicates the heading is skipped. This keeps header→value alignment
correct.
"""
if len(table_block) < 3:
return "\n".join(table_block)
headers = split_markdown_table_row(table_block[0])
if len(headers) < 2:
return "\n".join(table_block)
first_data_row = (
split_markdown_table_row(table_block[2])
if len(table_block) > 2
else []
)
has_row_label_col = len(first_data_row) == len(headers) + 1
rendered_groups: list[str] = []
for index, row in enumerate(table_block[2:], start=1):
cells = split_markdown_table_row(row)
if has_row_label_col:
heading = cells[0] if cells and cells[0] else f"Row {index}"
data_cells = cells[1:]
else:
heading = next((cell for cell in cells if cell), f"Row {index}")
data_cells = cells
if len(data_cells) < len(headers):
data_cells.extend([""] * (len(headers) - len(data_cells)))
elif len(data_cells) > len(headers):
data_cells = data_cells[: len(headers)]
bullets: list[str] = []
for header, value in zip(headers, data_cells):
if not has_row_label_col and value == heading:
continue
bullets.append(f"• {header}: {value}")
group_lines = [f"**{heading}**", *bullets]
rendered_groups.append("\n".join(group_lines))
return "\n\n".join(rendered_groups)
def convert_table_to_bullets(text: str) -> str:
"""Rewrite GFM pipe tables into bold-heading + bullet groups.
Tables inside fenced code blocks are left alone.
"""
if '|' not in text or '-' not in text:
return text
lines = text.split('\n')
out: list[str] = []
in_fence = False
i = 0
while i < len(lines):
line = lines[i]
stripped = line.lstrip()
if stripped.startswith('```'):
in_fence = not in_fence
out.append(line)
i += 1
continue
if in_fence:
out.append(line)
i += 1
continue
if (
'|' in line
and i + 1 < len(lines)
and TABLE_SEPARATOR_RE.match(lines[i + 1])
):
table_block = [line, lines[i + 1]]
j = i + 2
while j < len(lines) and is_table_row(lines[j]):
table_block.append(lines[j])
j += 1
out.append(_render_table_block(table_block))
i = j
continue
out.append(line)
i += 1
return '\n'.join(out)
# ─── Mention-pattern compilation ─────────────────────────────────────────────
def compile_mention_patterns(
raw,
*,
log_prefix: str,
platform_label: str | None = None,
display_label: str | None = None,
defaults: 'list[str] | None' = None,
logger_: 'logging.Logger | None' = None,
) -> 'list[re.Pattern]':
"""Compile regex wake-word/mention patterns from config or env values.
Two adapter families share this logic:
* **Config-style** (dingtalk, telegram): pass ``platform_label`` (e.g.
``"dingtalk"``). ``raw`` is the value from ``config.extra`` after env
fallback parsing; must be a list or string, anything else logs a warning
and yields ``[]``. Non-string entries are skipped. A summary info log is
emitted when patterns load.
* **Wakeword-style** (photon, bluebubbles): pass ``defaults``. ``raw`` may
be None (use defaults), a string (JSON list or comma/newline separated),
a list, or a scalar (wrapped in a list). Entries are coerced via
``str()``.
``log_prefix`` is interpolated into every log message so per-adapter log
output stays byte-identical to the historical inline implementations.
"""
log = logger_ or logger
if platform_label is not None:
# Config-style (dingtalk/telegram) semantics.
display = display_label or platform_label
patterns = raw
if patterns is None:
return []
if isinstance(patterns, str):
patterns = [patterns]
if not isinstance(patterns, list):
log.warning(
"[%s] %s mention_patterns must be a list or string; got %s",
log_prefix,
platform_label,
type(patterns).__name__,
)
return []
compiled: list[re.Pattern] = []
for pattern in patterns:
if not isinstance(pattern, str) or not pattern.strip():
continue
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
log.warning(
"[%s] Invalid %s mention pattern %r: %s",
log_prefix,
display,
pattern,
exc,
)
if compiled:
log.info(
"[%s] Loaded %d %s mention pattern(s)",
log_prefix,
len(compiled),
display,
)
return compiled
# Wakeword-style (photon/bluebubbles) semantics.
if raw is None:
patterns = list(defaults or [])
elif isinstance(raw, str):
text = raw.strip()
try:
loaded = json.loads(text) if text else []
except Exception:
loaded = None
patterns = loaded if isinstance(loaded, list) else [
part.strip()
for line in text.splitlines()
for part in line.split(",")
]
elif isinstance(raw, list):
patterns = raw
else:
patterns = [raw]
compiled = []
for pattern in patterns:
text = str(pattern).strip()
if not text:
continue
try:
compiled.append(re.compile(text, re.IGNORECASE))
except re.error as exc:
log.warning("[%s] Invalid mention pattern %r: %s", log_prefix, text, exc)
return compiled
# ─── Fence-Aware Markdown Chunking ───────────────────────────────────────────
# Shared core for the fence-aware markdown chunkers that previously lived as
# near-duplicates in gateway/stream_consumer.py, gateway/platforms/yuanbao.py
# (MarkdownProcessor — the richest version, which this core is derived from),
# and gateway/platforms/weixin.py. Each caller keeps its own knobs:
#
# * stream_consumer: newline-preferred splitting + close/reopen fence
# balancing (``prefer_paragraphs=False, balance_fences=True``)
# * yuanbao: atomic-block extraction + paragraph-boundary splitting, fences
# kept intact as atoms (``prefer_paragraphs=True, balance_fences=False``)
# * weixin: keeps its own block splitter (anchored ``_FENCE_RE``, per-line
# rstrip semantics) but reuses ``greedy_pack_blocks`` for packing.
#
# The typing helpers below use ``Optional``/``Callable`` from ``typing`` to
# match the module's existing import style.
def text_has_unclosed_fence(text: str) -> bool:
"""Return True when *text* ends inside an unclosed ``` code fence.
Scans line by line, toggling in/out state on lines starting with ```.
An odd number of toggles means the trailing fence is unclosed.
"""
in_fence = False
for line in text.split('\n'):
if line.startswith('```'):
in_fence = not in_fence
return in_fence
def text_ends_with_table_row(text: str) -> bool:
"""True when the last non-empty line starts and ends with ``|``."""
trimmed = text.rstrip()
if not trimmed:
return False
last_line = trimmed.split('\n')[-1].strip()
return last_line.startswith('|') and last_line.endswith('|')
def is_fence_atom(text: str) -> bool:
"""True when an atomic block is a code block (starts with ```)."""
return text.lstrip().startswith('```')
def is_table_atom(text: str) -> bool:
"""True when an atomic block is a table (first line is ``|...|``)."""
first_line = text.split('\n')[0].strip()
return first_line.startswith('|') and first_line.endswith('|')
_SENTENCE_END_NEWLINE_RE = re.compile(r'[。!?.!?]\n')
def split_at_paragraph_boundary(text, max_chars, len_fn=None):
"""Find the nearest paragraph boundary within *max_chars*; return (head, tail).
Split priority:
1. Blank line (paragraph boundary)
2. Newline after sentence-ending punctuation (CJK and ASCII)
3. Last newline
4. Force split at the *max_chars* window boundary
``head + tail == text`` always holds. *len_fn* allows measuring in
custom units (e.g. UTF-16 code units); a binary search finds the largest
prefix that fits when it is provided.
"""
_len = len_fn or len
if _len(text) <= max_chars:
return text, ''
if _len is len:
window = text[:max_chars]
else:
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if _len(text[:mid]) <= max_chars:
lo = mid
else:
hi = mid - 1
window = text[:lo]
# 1. Prefer the last blank line (\n\n) as paragraph boundary
pos = window.rfind('\n\n')
if pos > 0:
return text[:pos + 2], text[pos + 2:]
# 2. Then the last newline following sentence-ending punctuation
best_pos = -1
for m in _SENTENCE_END_NEWLINE_RE.finditer(window):
best_pos = m.end()
if best_pos > 0:
return text[:best_pos], text[best_pos:]
# 3. Fallback: last newline
pos = window.rfind('\n')
if pos > 0:
return text[:pos + 1], text[pos + 1:]
# 4. No valid split point: force split at the window boundary
cut = len(window)
return text[:cut], text[cut:]
def split_markdown_atoms(text: str) -> "list[str]":
"""Split markdown into indivisible "atomic blocks".
Atoms are: fenced code blocks (``` ... ``` inclusive), tables
(consecutive ``|...|`` lines), and plain paragraphs separated by blank
lines. Blank lines are separators and belong to no atom.
"""
lines = text.split('\n')
atoms: "list[str]" = []
current_lines: "list[str]" = []
in_fence = False
def _is_table_line(line: str) -> bool:
stripped = line.strip()
return stripped.startswith('|') and stripped.endswith('|')
def _flush_current() -> None:
if current_lines:
atom = '\n'.join(current_lines)
if atom.strip():
atoms.append(atom)
current_lines.clear()
for line in lines:
if in_fence:
current_lines.append(line)
if line.startswith('```') and len(current_lines) > 1:
in_fence = False
_flush_current()
elif line.startswith('```'):
_flush_current()
in_fence = True
current_lines.append(line)
elif _is_table_line(line):
if current_lines and not _is_table_line(current_lines[-1]):
_flush_current()
current_lines.append(line)
elif line.strip() == '':
_flush_current()
else:
if current_lines and _is_table_line(current_lines[-1]):
_flush_current()
current_lines.append(line)
_flush_current()
return atoms
def infer_block_separator(prev_chunk: str, next_chunk: str) -> str:
"""Infer the separator (``'\\n'`` or ``'\\n\\n'``) between two chunks.
Single newline when the boundary sits at a code fence or a continued
table; paragraph separator otherwise.
"""
prev_trimmed = prev_chunk.rstrip()
next_trimmed = next_chunk.lstrip()
if prev_trimmed.endswith('```') or next_trimmed.startswith('```'):
return '\n'
if text_ends_with_table_row(prev_chunk):
first_line = next_trimmed.split('\n')[0].strip() if next_trimmed else ''
if first_line.startswith('|') and first_line.endswith('|'):
return '\n'
return '\n\n'
def merge_streaming_fences(chunks: "list[str]") -> "list[str]":
"""Stream-aware fence merge: rejoin chunks truncated mid-fence.
While chunk *i* has an unclosed fence and a successor exists, merge the
successor into it using :func:`infer_block_separator`.
"""
if not chunks:
return []
result: "list[str]" = []
i = 0
while i < len(chunks):
current = chunks[i]
while text_has_unclosed_fence(current) and i + 1 < len(chunks):
sep = infer_block_separator(current, chunks[i + 1])
current = current + sep + chunks[i + 1]
i += 1
result.append(current)
i += 1
return result
def balance_fences_across_chunks(chunks: "list[str]") -> "list[str]":
"""Close orphaned ``` fences at each chunk boundary and reopen on the next.
When a split lands inside a triple-backtick code block, close the fence
at the end of the head chunk and reopen it (with the original language
tag) at the start of the next, so every delivered chunk is
fence-balanced on its own.
"""
if len(chunks) <= 1:
return chunks
out: "list[str]" = []
carry_lang = None
for chunk in chunks:
prefix = f"```{carry_lang}\n" if carry_lang is not None else ""
in_code = carry_lang is not None
lang = carry_lang or ""
for line in chunk.split("\n"):
stripped = line.strip()
if stripped.startswith("```"):
if in_code:
in_code = False
lang = ""
else:
in_code = True
tag = stripped[3:].strip()
lang = tag.split()[0] if tag else ""
body = prefix + chunk
if in_code:
body += "\n```"
carry_lang = lang
else:
carry_lang = None
out.append(body)
return out
def greedy_pack_blocks(blocks, max_length, len_fn=None, sep="\n\n", overflow=None):
"""Greedily pack pre-split *blocks* into chunks of at most *max_length*.
Blocks are joined with *sep* while they fit. A block that alone exceeds
the limit is passed to *overflow(block)* (which must return a list of
chunks) when provided, else emitted as-is.
"""
_len = len_fn or len
packed: "list[str]" = []
current = ""
for block in blocks:
candidate = block if not current else f"{current}{sep}{block}"
if _len(candidate) <= max_length:
current = candidate
continue
if current:
packed.append(current)
current = ""
if _len(block) <= max_length:
current = block
continue
if overflow is not None:
packed.extend(overflow(block))
else:
packed.append(block)
if current:
packed.append(current)
return packed
def split_text_fence_aware(
text,
limit,
len_fn=None,
*,
prefer_paragraphs=True,
balance_fences=False,
):
"""Split markdown text into chunks of at most *limit*, respecting fences.
Two strategies, selected by ``prefer_paragraphs``:
``prefer_paragraphs=True`` (yuanbao-derived, the richest):
Extract atomic blocks (code fences, tables, paragraphs), greedily merge
them up to *limit*, split still-oversized non-atomic chunks at
paragraph boundaries, then re-merge small neighbours. Code blocks and
tables are never split in the middle; a single atom larger than
*limit* is emitted oversize rather than broken.
``prefer_paragraphs=False`` (stream_consumer-derived):
Newline-preferred hard splitting with headroom reserved for fence
markers when the text contains ```.
``balance_fences=True`` post-processes the chunks so a split inside a
code block closes the fence on the head chunk and reopens it (with the
language tag) on the tail — required by callers whose chunks are
delivered as independent messages that each must render standalone.
"""
_len = len_fn or len
if not text:
return []
if prefer_paragraphs:
chunks = _chunk_markdown_paragraphs(text, limit, len_fn)
else:
chunks = _chunk_newline_preferred(text, limit, _len)
if balance_fences:
chunks = balance_fences_across_chunks(chunks)
return chunks
def _chunk_markdown_paragraphs(text, max_chars, len_fn=None):
"""Yuanbao-derived paragraph/atom chunking pipeline (see module docs)."""
_len = len_fn or len
if _len(text) <= max_chars:
return [text]
# Phase 1: Extract atomic blocks
atoms = split_markdown_atoms(text)
# Phase 2: Greedy merge
chunks: "list[str]" = []
indivisible_set: "set[int]" = set()
current_parts: "list[str]" = []
current_len = 0
def _flush_parts() -> None:
if current_parts:
chunks.append('\n\n'.join(current_parts))
for atom in atoms:
atom_len = _len(atom)
sep_len = 2 if current_parts else 0
projected_len = current_len + sep_len + atom_len
if projected_len > max_chars and current_parts:
_flush_parts()
current_parts = []
current_len = 0
sep_len = 0
if (not current_parts
and atom_len > max_chars
and (is_fence_atom(atom) or is_table_atom(atom))):
indivisible_set.add(len(chunks))
chunks.append(atom)
continue
current_parts.append(atom)
current_len += sep_len + atom_len
_flush_parts()
# Phase 3: Split still-oversized chunks at paragraph boundaries
result: "list[str]" = []
for idx, chunk in enumerate(chunks):
if _len(chunk) <= max_chars:
result.append(chunk)
continue
if idx in indivisible_set:
result.append(chunk)
continue
if text_has_unclosed_fence(chunk):
result.append(chunk)
continue
remaining = chunk
while _len(remaining) > max_chars:
head, remaining = split_at_paragraph_boundary(
remaining, max_chars, len_fn=len_fn,
)
if not head:
head, remaining = remaining[:max_chars], remaining[max_chars:]
if head:
result.append(head)
if remaining:
result.append(remaining)
# Phase 4: Merge small trailing/leading chunks with neighbours
if len(result) > 1:
merged: "list[str]" = [result[0]]
for chunk in result[1:]:
prev = merged[-1]
combined = prev + '\n\n' + chunk
if _len(combined) <= max_chars:
merged[-1] = combined
else:
merged.append(chunk)
result = merged
return [c for c in result if c]
def _chunk_newline_preferred(text, limit, len_fn):
"""Stream-consumer-derived newline-preferred splitting (no balancing)."""
if len_fn(text) <= limit:
return [text]
# Reserve headroom for the close/reopen fence markers a balancing pass
# may add, so balanced chunks stay within the platform limit.
split_limit = limit
if "```" in text:
split_limit = max(limit - 16, limit // 2, 1)
# Local import: gateway.platforms.base is heavyweight and pulls config;
# helpers must stay import-light for adapters that import it first.
from gateway.platforms.base import _custom_unit_to_cp
chunks: "list[str]" = []
remaining = text
while len_fn(remaining) > split_limit:
_cp_budget = _custom_unit_to_cp(remaining, split_limit, len_fn)
split_at = remaining.rfind("\n", 0, _cp_budget)
if split_at < _cp_budget // 2:
split_at = _cp_budget
chunks.append(remaining[:split_at])
remaining = remaining[split_at:].lstrip("\n")
if remaining:
chunks.append(remaining)
return chunks
+202
View File
@@ -0,0 +1,202 @@
"""Shared mime↔extension dispatch for inbound (downloaded) platform media.
Historically every gateway adapter hand-rolled its own mime→extension map
before handing downloaded bytes to the cache primitives in
``gateway.platforms.base`` (``cache_image_from_bytes``,
``cache_audio_from_bytes``, ``cache_document_from_bytes``). Those maps
*disagree* with each other on purpose — e.g. BlueBubbles coerces
``image/heic`` to ``.jpg`` because downstream vision tools can't read HEIC,
while WhatsApp Cloud pins ``audio/ogg`` to ``.ogg`` (not the RFC-correct
``.oga`` Python's ``mimetypes`` returns) because the STT pipeline whitelists
extensions.
This module owns:
* ``DEFAULT_MIME_TO_EXT`` — the union table of entries the adapters already
agree on (plus a few uncontroversial document types).
* ``DEFAULT_EXT_TO_MIME`` — the canonical inverse (used by Signal to map a
sniffed extension back to a content type).
* ``ext_for_mime`` / ``mime_for_ext`` — lookup helpers that accept
per-adapter ``overrides`` so each adapter's historical (divergent)
behavior is preserved byte-for-byte.
* ``cache_media_bytes`` — one-call dispatch: classify the mime, resolve the
extension, and write to the right cache (image / audio / document).
Behavior-preservation contract: adapters that had divergent maps pass them
as ``overrides`` (and, where their historical code never consulted
``mimetypes`` or a shared table, disable those fallbacks via
``use_defaults`` / ``use_mimetypes``). The parity tests in
``tests/gateway/test_media_cache.py`` hardcode the historical outputs as
the contract.
NOTE: ``gateway/platforms/weixin.py`` also has a private mime map
(``_mime_from_filename``) but is intentionally NOT migrated here — another
in-flight branch edits that file. Follow-up: fold it in once that lands.
"""
from __future__ import annotations
import mimetypes
import uuid
from typing import Mapping, Optional
# ---------------------------------------------------------------------------
# Shared tables
# ---------------------------------------------------------------------------
# Union of the per-adapter maps where the adapters already agree (or where
# only one adapter pinned the type and no other adapter contradicts it).
# Entries deliberately favor the common-in-the-wild extension over the
# RFC-correct one (``audio/ogg`` → ``.ogg``, not ``.oga``) because the
# downstream STT/vision pipelines whitelist real-world extensions.
DEFAULT_MIME_TO_EXT: dict[str, str] = {
# --- images (bluebubbles + whatsapp_cloud agree; matches mimetypes) ---
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
# --- audio ---
"audio/ogg": ".ogg", # bluebubbles + whatsapp_cloud agree
"audio/x-opus+ogg": ".ogg", # whatsapp voice notes (opus-in-ogg)
"audio/opus": ".ogg", # whatsapp voice notes (opus-in-ogg)
"audio/mpeg": ".mp3",
"audio/mp3": ".mp3", # non-standard but seen in the wild
"audio/wav": ".wav",
"audio/mp4": ".m4a", # bluebubbles + whatsapp_cloud agree
"audio/x-m4a": ".m4a",
"audio/aac": ".aac",
# --- video / documents (from signal's inverse table) ---
"video/mp4": ".mp4",
"application/pdf": ".pdf",
"application/zip": ".zip",
}
# Canonical inverse. Kept explicit (rather than mechanically inverted)
# because the forward table is many-to-one — e.g. both ``audio/mpeg`` and
# ``audio/mp3`` map to ``.mp3`` and the inverse must pick the canonical
# mime. This is byte-identical to Signal's historical ``_EXT_TO_MIME``.
DEFAULT_EXT_TO_MIME: dict[str, str] = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".gif": "image/gif", ".webp": "image/webp",
".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav",
".m4a": "audio/mp4", ".aac": "audio/aac",
".mp4": "video/mp4", ".pdf": "application/pdf",
".zip": "application/zip",
}
def _normalize_mime(mime: str) -> str:
"""Lowercase and strip any ``; charset=...`` style parameters."""
return (mime or "").split(";")[0].strip().lower()
# ---------------------------------------------------------------------------
# Lookups
# ---------------------------------------------------------------------------
def ext_for_mime(
mime: str,
*,
overrides: Optional[Mapping[str, str]] = None,
use_defaults: bool = True,
use_mimetypes: bool = True,
fallback: Optional[str] = None,
) -> Optional[str]:
"""Resolve a mime type to a file extension (including the dot).
Resolution order: ``overrides`` → ``DEFAULT_MIME_TO_EXT`` (if
``use_defaults``) → ``mimetypes.guess_extension`` (if
``use_mimetypes``) → ``fallback``.
Adapters with historical divergent maps pass them via ``overrides``
and disable the stages their old code never consulted, keeping their
outputs byte-identical to the pre-refactor behavior.
"""
primary = _normalize_mime(mime)
if not primary:
return fallback
if overrides:
ext = overrides.get(primary)
if ext:
return ext
if use_defaults:
ext = DEFAULT_MIME_TO_EXT.get(primary)
if ext:
return ext
if use_mimetypes:
ext = mimetypes.guess_extension(primary)
if ext:
return ext
return fallback
def mime_for_ext(
ext: str,
*,
overrides: Optional[Mapping[str, str]] = None,
fallback: str = "application/octet-stream",
) -> str:
"""Inverse lookup: file extension → canonical mime type.
Resolution order: ``overrides`` → ``DEFAULT_EXT_TO_MIME`` → ``fallback``.
"""
key = (ext or "").strip().lower()
if overrides:
mime = overrides.get(key)
if mime:
return mime
return DEFAULT_EXT_TO_MIME.get(key, fallback)
# ---------------------------------------------------------------------------
# One-call cache dispatch
# ---------------------------------------------------------------------------
def cache_media_bytes(
data: bytes,
mime: str,
*,
filename_hint: str = "",
kind_hint: Optional[str] = None,
ext_overrides: Optional[Mapping[str, str]] = None,
) -> str:
"""Cache downloaded media bytes and return the local file path.
Picks the image / audio / document cache primitive from
``gateway.platforms.base`` based on the mime class (or an explicit
``kind_hint`` of ``"image"``, ``"audio"`` or ``"document"``).
``filename_hint`` is used for document caching (falls back to a
generated name with the resolved extension). ``ext_overrides`` is
threaded through to :func:`ext_for_mime` for adapters that need their
historical mappings.
"""
# Local import: base is a large module and some adapters import this
# module very early; keep import-time coupling minimal.
from gateway.platforms.base import (
cache_audio_from_bytes,
cache_document_from_bytes,
cache_image_from_bytes,
)
primary = _normalize_mime(mime)
kind = kind_hint
if kind is None:
if primary.startswith("image/"):
kind = "image"
elif primary.startswith("audio/"):
kind = "audio"
else:
kind = "document"
if kind == "image":
ext = ext_for_mime(primary, overrides=ext_overrides, fallback=".jpg") or ".jpg"
return cache_image_from_bytes(data, ext)
if kind == "audio":
ext = ext_for_mime(primary, overrides=ext_overrides, fallback=".ogg") or ".ogg"
return cache_audio_from_bytes(data, ext)
filename = filename_hint
if not filename:
ext = ext_for_mime(primary, overrides=ext_overrides, fallback=".bin")
filename = f"file_{uuid.uuid4().hex[:8]}{ext}"
return cache_document_from_bytes(data, filename)
+457
View File
@@ -0,0 +1,457 @@
"""Microsoft Graph webhook adapter for change-notification ingress."""
from __future__ import annotations
import asyncio
import hmac
import ipaddress
import json
import logging
from collections import deque
from hashlib import sha1
from typing import Any, Awaitable, Callable, Dict, Optional
try:
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
web = None # type: ignore[assignment]
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
is_network_accessible,
)
logger = logging.getLogger(__name__)
# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per
# address family (IPv4 + IPv6). The old "0.0.0.0" default bound IPv4 ONLY and
# was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — same
# bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py
# (d542894ad). Pin a host via extra.host. The all-interfaces default still
# requires extra.allowed_source_cidrs (see _source_allowlist_required_but_missing).
DEFAULT_HOST = None
DEFAULT_PORT = 8646
DEFAULT_WEBHOOK_PATH = "/msgraph/webhook"
DEFAULT_MAX_SEEN_RECEIPTS = 5000
DEFAULT_MAX_BODY_BYTES = 1_048_576
NotificationScheduler = Callable[[Dict[str, Any], MessageEvent], Awaitable[None] | None]
def check_msgraph_webhook_requirements() -> bool:
"""Return whether required webhook dependencies are available."""
return AIOHTTP_AVAILABLE
class MSGraphWebhookAdapter(BasePlatformAdapter):
"""Receive Microsoft Graph change notifications and surface them internally."""
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MSGRAPH_WEBHOOK)
extra = config.extra or {}
# Falsy host (None/"") collapses to the dual-stack default.
_raw_host = extra.get("host", DEFAULT_HOST) or DEFAULT_HOST
self._host: Optional[str] = str(_raw_host) if _raw_host else None
self._port: int = int(extra.get("port", DEFAULT_PORT))
self._webhook_path: str = self._normalize_path(
extra.get("webhook_path", DEFAULT_WEBHOOK_PATH)
)
self._health_path: str = self._normalize_path(extra.get("health_path", "/health"))
self._accepted_resources: list[str] = [
str(value).strip()
for value in (extra.get("accepted_resources") or [])
if str(value).strip()
]
self._client_state: Optional[str] = self._string_or_none(extra.get("client_state"))
self._max_seen_receipts = max(
1, int(extra.get("max_seen_receipts", DEFAULT_MAX_SEEN_RECEIPTS))
)
self._max_body_bytes = max(
1, int(extra.get("max_body_bytes", DEFAULT_MAX_BODY_BYTES))
)
self._allowed_source_networks: list[ipaddress._BaseNetwork] = (
self._parse_allowed_source_cidrs(extra.get("allowed_source_cidrs"))
)
self._runner = None
self._notification_scheduler: Optional[NotificationScheduler] = None
self._seen_receipts: set[str] = set()
self._seen_receipt_order: deque[str] = deque()
self._accepted_count = 0
self._duplicate_count = 0
@staticmethod
def _string_or_none(value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
return text or None
@staticmethod
def _normalize_path(path: Any) -> str:
raw = str(path or "").strip() or "/"
return raw if raw.startswith("/") else f"/{raw}"
@staticmethod
def _build_receipt_key(notification: Dict[str, Any]) -> Optional[str]:
explicit_id = str(notification.get("id") or "").strip()
if explicit_id:
return f"id:{explicit_id}"
return None
@staticmethod
def _normalize_resource_value(resource: str) -> str:
return str(resource or "").strip().strip("/")
@staticmethod
def _parse_allowed_source_cidrs(
raw: Any,
) -> list[ipaddress._BaseNetwork]:
"""Parse an optional list of CIDR ranges allowed to POST to the webhook.
An empty or missing value means "allow everything" (same behavior as
before this field existed). When populated, requests from source IPs
outside every listed CIDR are rejected with 403 before the body is
parsed. Use this to restrict the endpoint to Microsoft Graph's
published webhook source ranges in production deployments.
"""
if raw is None:
return []
if isinstance(raw, str):
candidates = [chunk.strip() for chunk in raw.split(",")]
elif isinstance(raw, (list, tuple, set)):
candidates = [str(chunk).strip() for chunk in raw]
else:
return []
networks: list[ipaddress._BaseNetwork] = []
for chunk in candidates:
if not chunk:
continue
try:
networks.append(ipaddress.ip_network(chunk, strict=False))
except ValueError:
logger.warning(
"[msgraph_webhook] Ignoring invalid allowed_source_cidrs entry: %r",
chunk,
)
return networks
def set_notification_scheduler(self, scheduler: Optional[NotificationScheduler]) -> None:
self._notification_scheduler = scheduler
def _source_allowlist_required_but_missing(self) -> bool:
# host=None binds all interfaces (both families) — network-accessible.
host_is_public = self._host is None or is_network_accessible(self._host)
return host_is_public and not self._allowed_source_networks
async def connect(self, *, is_reconnect: bool = False) -> bool:
if self._client_state is None:
logger.error(
"[msgraph_webhook] Refusing to start without extra.client_state configured"
)
return False
if self._source_allowlist_required_but_missing():
logger.error(
"[msgraph_webhook] Refusing to start: binding to %s requires "
"extra.allowed_source_cidrs. Configure the Microsoft Graph "
"source CIDRs or bind to loopback (127.0.0.1/::1) behind a "
"tunnel or reverse proxy.",
self._host,
)
return False
app = web.Application(client_max_size=self._max_body_bytes)
app.router.add_get(self._health_path, self._handle_health)
app.router.add_get(self._webhook_path, self._handle_validation)
app.router.add_post(self._webhook_path, self._handle_notification)
# Plugin-registered native handlers (aiohttp web.Application —
# router routes). Wired before AppRunner.setup() freezes the router.
self._wire_plugin_handlers(app)
self._runner = web.AppRunner(app)
await self._runner.setup()
site = web.TCPSite(self._runner, self._host, self._port)
await site.start()
self._mark_connected()
logger.info(
"[msgraph_webhook] Listening on %s:%d%s",
self._host,
self._port,
self._webhook_path,
)
return True
async def disconnect(self) -> None:
if self._runner is not None:
await self._runner.cleanup()
self._runner = None
self._mark_disconnected()
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
logger.info("[msgraph_webhook] Response for %s: %s", chat_id, content[:200])
return SendResult(success=True)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": chat_id, "type": "webhook"}
async def _handle_health(self, request: "web.Request") -> "web.Response":
if not self._source_ip_allowed(request):
return web.Response(status=403)
return web.json_response(
{
"status": "ok",
"platform": self.platform.value,
"webhook_path": self._webhook_path,
"accepted": self._accepted_count,
"duplicates": self._duplicate_count,
}
)
async def _handle_validation(self, request: "web.Request") -> "web.Response":
"""Handle Microsoft Graph subscription validation handshake.
Graph validates a subscription endpoint by sending a GET with
``validationToken`` in the query string; the service must echo the
token verbatim as ``text/plain`` within 10 seconds. Anything else
(bare GET, GET without the token) is rejected so the endpoint can't
be enumerated or mistakenly used for data exfiltration.
"""
if not self._source_ip_allowed(request):
return web.Response(status=403)
validation_token = request.query.get("validationToken", "")
if not validation_token:
return web.Response(status=400)
return web.Response(text=validation_token, content_type="text/plain")
async def _handle_notification(self, request: "web.Request") -> "web.Response":
if not self._source_ip_allowed(request):
return web.Response(status=403)
# Graph never sends validationToken on POST, but tolerate it for
# defensive clients that replay the handshake in-band.
validation_token = request.query.get("validationToken", "")
if validation_token:
return web.Response(text=validation_token, content_type="text/plain")
try:
content_length = request.content_length
except Exception:
content_length = None
if content_length is not None and content_length > self._max_body_bytes:
return web.Response(status=413)
try:
raw_body = await request.read()
except Exception:
return web.Response(status=400)
if len(raw_body) > self._max_body_bytes:
return web.Response(status=413)
try:
body = json.loads(raw_body.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return web.Response(status=400)
if not isinstance(body, dict):
return web.Response(status=400)
notifications = body.get("value")
if not isinstance(notifications, list):
return web.Response(status=400)
accepted = 0
duplicates = 0
auth_rejected = 0
other_rejected = 0
for raw_notification in notifications:
if not isinstance(raw_notification, dict):
other_rejected += 1
continue
notification = dict(raw_notification)
if not self._resource_accepted(str(notification.get("resource") or "")):
other_rejected += 1
continue
if not self._verify_client_state(notification):
# Treat bad clientState as an auth failure: if the whole
# batch is forged, we want to signal 403 so the sender
# stops retrying. Legitimate Graph retries have valid
# clientState and hit the accepted/duplicate paths.
auth_rejected += 1
continue
receipt_key = self._build_receipt_key(notification)
if receipt_key is not None:
if self._has_seen_receipt(receipt_key):
duplicates += 1
continue
self._remember_receipt(receipt_key)
accepted += 1
self._accepted_count += 1
event = self._build_message_event(notification, receipt_key)
self._schedule_notification(notification, event)
self._duplicate_count += duplicates
# If anything ingested OR deduped, return 202 with empty body so
# Graph acks successfully and we don't leak internal counters. If
# every item failed auth, return 403 so an attacker POSTing fake
# notifications gets a clear reject. Other failures (malformed,
# resource-not-accepted) are the sender's configuration problem,
# so 400.
if accepted or duplicates:
return web.Response(status=202)
if auth_rejected and not other_rejected:
return web.Response(status=403)
return web.Response(status=400)
def _source_ip_allowed(self, request: "web.Request") -> bool:
"""Return True if the request's source IP is in the configured allowlist.
Loopback-only binds may omit ``allowed_source_cidrs`` for local reverse
proxies and dev tunnels. Network-accessible binds fail closed until an
explicit CIDR allowlist is configured.
"""
if self._source_allowlist_required_but_missing():
return False
if not self._allowed_source_networks:
return True
peer = request.remote or ""
if not peer:
return False
try:
peer_addr = ipaddress.ip_address(peer)
except ValueError:
return False
return any(peer_addr in network for network in self._allowed_source_networks)
def _resource_accepted(self, resource: str) -> bool:
if not self._accepted_resources:
return True
normalized_resource = self._normalize_resource_value(resource)
for pattern in self._accepted_resources:
normalized_pattern = self._normalize_resource_value(pattern)
if not normalized_pattern:
continue
if normalized_pattern.endswith("*"):
prefix = normalized_pattern[:-1].rstrip("/")
if normalized_resource == prefix or normalized_resource.startswith(f"{prefix}/"):
return True
continue
if (
normalized_resource == normalized_pattern
or normalized_resource.startswith(f"{normalized_pattern}/")
):
return True
return False
def _verify_client_state(self, notification: Dict[str, Any]) -> bool:
"""Verify the Graph-supplied clientState matches the configured secret.
Uses ``hmac.compare_digest`` instead of ``==`` so that a mismatch
doesn't leak how many leading characters matched via string-compare
timing. The configured client_state is a shared secret (documented in
the setup guide as "generate with ``openssl rand -hex 32``"), so a
timing-safe compare is the right primitive.
"""
expected = self._client_state
if expected is None:
return False
provided = self._string_or_none(notification.get("clientState"))
if provided is None:
return False
# Compare as bytes: ``compare_digest`` raises TypeError on a str with
# non-ASCII characters, and clientState comes from the request body.
return hmac.compare_digest(provided.encode(), expected.encode())
def _has_seen_receipt(self, receipt_key: str) -> bool:
return receipt_key in self._seen_receipts
def _remember_receipt(self, receipt_key: str) -> None:
self._seen_receipts.add(receipt_key)
self._seen_receipt_order.append(receipt_key)
while len(self._seen_receipt_order) > self._max_seen_receipts:
oldest = self._seen_receipt_order.popleft()
self._seen_receipts.discard(oldest)
def _build_message_event(
self,
notification: Dict[str, Any],
receipt_key: Optional[str],
) -> MessageEvent:
message_id = receipt_key or f"sha1:{sha1(json.dumps(notification, sort_keys=True).encode('utf-8')).hexdigest()}"
source = self.build_source(
chat_id=f"msgraph:{notification.get('subscriptionId', 'unknown')}",
chat_name="msgraph/webhook",
chat_type="webhook",
user_id="msgraph",
user_name="Microsoft Graph",
)
return MessageEvent(
text=self._render_prompt(notification),
message_type=MessageType.TEXT,
source=source,
raw_message=notification,
message_id=message_id,
internal=True,
)
def _render_prompt(self, notification: Dict[str, Any]) -> str:
template = self.config.extra.get("prompt", "")
if template:
payload = {
"notification": notification,
"resource": notification.get("resource", ""),
"change_type": notification.get("changeType", ""),
"subscription_id": notification.get("subscriptionId", ""),
}
return self._render_template(template, payload)
rendered = json.dumps(notification, indent=2, sort_keys=True)[:4000]
return f"Microsoft Graph change notification:\n\n```json\n{rendered}\n```"
def _render_template(self, template: str, payload: Dict[str, Any]) -> str:
import re
def _resolve(match: "re.Match[str]") -> str:
key = match.group(1)
value: Any = payload
for part in key.split("."):
if isinstance(value, dict):
value = value.get(part, f"{{{key}}}")
else:
return f"{{{key}}}"
if isinstance(value, (dict, list)):
return json.dumps(value, sort_keys=True)[:2000]
return str(value)
return re.sub(r"\{([a-zA-Z0-9_.]+)\}", _resolve, template)
def _schedule_notification(
self,
notification: Dict[str, Any],
event: MessageEvent,
) -> None:
scheduler = self._notification_scheduler
if scheduler is not None:
result = scheduler(notification, event)
if asyncio.iscoroutine(result):
task = asyncio.create_task(result)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return
task = asyncio.create_task(self.handle_message(event))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
+91
View File
@@ -0,0 +1,91 @@
"""
QQBot platform package.
Re-exports the main adapter symbols from ``adapter.py`` (the original
``qqbot.py``) so that **all existing import paths remain unchanged**::
from gateway.platforms.qqbot import QQAdapter # works
from gateway.platforms.qqbot import check_qq_requirements # works
New modules:
- ``constants`` — shared constants (API URLs, timeouts, message types)
- ``utils`` — User-Agent builder, config helpers
- ``crypto`` — AES-256-GCM key generation and decryption
- ``onboard`` — QR-code scan-to-configure flow
"""
# -- Adapter (original qqbot.py) ------------------------------------------
from .adapter import ( # noqa: F401
QQAdapter,
QQCloseError,
check_qq_requirements,
_coerce_list,
_ssrf_redirect_guard,
)
# -- Onboard (QR-code scan-to-configure) -----------------------------------
from .onboard import ( # noqa: F401
BindStatus,
build_connect_url,
qr_register,
)
from .crypto import decrypt_secret, generate_bind_key # noqa: F401
# -- Utils -----------------------------------------------------------------
from .utils import build_user_agent, get_api_headers, coerce_list # noqa: F401
# -- Chunked upload --------------------------------------------------------
from .chunked_upload import ( # noqa: F401
ChunkedUploader,
UploadDailyLimitExceededError,
UploadFileTooLargeError,
)
# -- Inline keyboards ------------------------------------------------------
from .keyboards import ( # noqa: F401
ApprovalRequest,
ApprovalSender,
InlineKeyboard,
InteractionEvent,
build_approval_keyboard,
build_approval_text,
build_update_prompt_keyboard,
parse_approval_button_data,
parse_interaction_event,
parse_update_prompt_button_data,
)
__all__ = [
# adapter
"QQAdapter",
"QQCloseError",
"check_qq_requirements",
"_coerce_list",
"_ssrf_redirect_guard",
# onboard
"BindStatus",
"build_connect_url",
"qr_register",
# crypto
"decrypt_secret",
"generate_bind_key",
# utils
"build_user_agent",
"get_api_headers",
"coerce_list",
# chunked upload
"ChunkedUploader",
"UploadDailyLimitExceededError",
"UploadFileTooLargeError",
# keyboards
"ApprovalRequest",
"ApprovalSender",
"InlineKeyboard",
"InteractionEvent",
"build_approval_keyboard",
"build_approval_text",
"build_update_prompt_keyboard",
"parse_approval_button_data",
"parse_interaction_event",
"parse_update_prompt_button_data",
]
File diff suppressed because it is too large. Load diff
+602
View File
@@ -0,0 +1,602 @@
"""QQ Bot chunked upload flow.
The QQ v2 API caps inline base64 uploads (``file_data`` / ``url``) at ~10 MB.
For files between 10 MB and ~100 MB we have to use the three-step chunked
upload flow::
1. POST /v2/{users|groups}/{id}/upload_prepare
→ returns upload_id, block_size, and an array of pre-signed COS part URLs.
2. For each part:
PUT the part bytes to its pre-signed COS URL,
then POST /v2/{users|groups}/{id}/upload_part_finish to acknowledge.
3. POST /v2/{users|groups}/{id}/files with {"upload_id": ...}
→ returns the ``file_info`` token the caller uses in a RichMedia
message.
Error-code semantics (from the QQ Bot v2 API spec):
- ``40093001`` — ``upload_part_finish`` retryable. Retry until the server-provided
``retry_timeout`` elapses (or a local cap).
- ``40093002`` — daily cumulative upload quota exceeded. Not retryable; surface
as :class:`UploadDailyLimitExceededError` so the caller can build a
user-friendly reply.
Exceptions:
- :class:`UploadDailyLimitExceededError` — daily quota hit (non-retryable).
- :class:`UploadFileTooLargeError` — file exceeds the platform per-file limit.
- :class:`RuntimeError` — generic upload failure (network, part PUT, complete).
Ported from WideLee's qqbot-agent-sdk v1.2.2 (``media_loader.py::ChunkedUploader``)
so the heavy-upload path stays in-tree. Authorship preserved via Co-authored-by.
"""
from __future__ import annotations
import asyncio
import functools
import hashlib
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional
from gateway.platforms.qqbot.constants import FILE_UPLOAD_TIMEOUT
logger = logging.getLogger(__name__)
# ── Error codes ──────────────────────────────────────────────────────
_BIZ_CODE_DAILY_LIMIT = 40093002 # upload_prepare: daily cumulative limit
_BIZ_CODE_PART_RETRYABLE = 40093001 # upload_part_finish: transient
# ── Part upload tuning ───────────────────────────────────────────────
_DEFAULT_CONCURRENT_PARTS = 1
_MAX_CONCURRENT_PARTS = 10
_PART_UPLOAD_TIMEOUT = 300.0 # 5 minutes per COS PUT
_PART_UPLOAD_MAX_RETRIES = 2
_PART_FINISH_RETRY_INTERVAL = 1.0
_PART_FINISH_DEFAULT_TIMEOUT = 120.0
_PART_FINISH_MAX_TIMEOUT = 600.0
_COMPLETE_UPLOAD_MAX_RETRIES = 2
_COMPLETE_UPLOAD_BASE_DELAY = 2.0
# First 10,002,432 bytes used for the ``md5_10m`` hash (per QQ API spec).
_MD5_10M_SIZE = 10_002_432
# ── Exceptions ───────────────────────────────────────────────────────
class UploadDailyLimitExceededError(Exception):
"""Raised when ``upload_prepare`` returns biz_code 40093002.
The daily cumulative upload quota for this bot has been reached. Callers
should surface :attr:`file_name` + :attr:`file_size_human` so the model
can compose a helpful reply.
"""
def __init__(self, file_name: str, file_size: int, message: str = "") -> None:
self.file_name = file_name
self.file_size = file_size
super().__init__(
message or f"Daily upload limit exceeded for {file_name!r}"
)
@property
def file_size_human(self) -> str:
return format_size(self.file_size)
class UploadFileTooLargeError(Exception):
"""Raised when a file exceeds the platform per-file size limit."""
def __init__(
self,
file_name: str,
file_size: int,
limit_bytes: int = 0,
message: str = "",
) -> None:
self.file_name = file_name
self.file_size = file_size
self.limit_bytes = limit_bytes
limit_str = f" ({format_size(limit_bytes)})" if limit_bytes else ""
super().__init__(
message
or (
f"File {file_name!r} ({format_size(file_size)}) "
f"exceeds platform limit{limit_str}"
)
)
@property
def file_size_human(self) -> str:
return format_size(self.file_size)
@property
def limit_human(self) -> str:
return format_size(self.limit_bytes) if self.limit_bytes else "unknown"
# ── Progress tracking ────────────────────────────────────────────────
@dataclass
class _UploadProgress:
total_parts: int = 0
total_bytes: int = 0
completed_parts: int = 0
uploaded_bytes: int = 0
# ── Prepare-response shape ───────────────────────────────────────────
@dataclass
class _PreparePart:
index: int
presigned_url: str
block_size: int = 0
@dataclass
class _PrepareResult:
upload_id: str
block_size: int
parts: List[_PreparePart]
concurrency: int = _DEFAULT_CONCURRENT_PARTS
retry_timeout: float = 0.0
def _parse_prepare_response(raw: Dict[str, Any]) -> _PrepareResult:
"""Parse the upload_prepare API response into a normalized shape.
The API may return the response directly or wrapped in ``data``.
"""
src = raw.get("data") if isinstance(raw.get("data"), dict) else raw
upload_id = str(src.get("upload_id", ""))
if not upload_id:
raise ValueError(
f"upload_prepare response missing upload_id: {str(raw)[:200]}"
)
block_size = int(src.get("block_size", 0))
raw_parts = src.get("parts") or src.get("part_list") or []
if not isinstance(raw_parts, list) or not raw_parts:
raise ValueError(
f"upload_prepare response missing parts: {str(raw)[:200]}"
)
parts: List[_PreparePart] = []
for p in raw_parts:
if not isinstance(p, dict):
continue
parts.append(
_PreparePart(
index=int(p.get("part_index") or p.get("index") or 0),
presigned_url=str(
p.get("presigned_url") or p.get("url") or ""
),
block_size=int(p.get("block_size", 0)),
)
)
return _PrepareResult(
upload_id=upload_id,
block_size=block_size,
parts=parts,
concurrency=int(src.get("concurrency", _DEFAULT_CONCURRENT_PARTS)) or _DEFAULT_CONCURRENT_PARTS,
retry_timeout=float(src.get("retry_timeout", 0.0) or 0.0),
)
# ── Chunked upload driver ────────────────────────────────────────────
ApiRequestFn = Callable[..., Awaitable[Dict[str, Any]]]
"""Signature of the adapter's ``_api_request`` callable.
We pass the bound method in rather than importing the adapter, to avoid
circular imports and keep this module testable in isolation.
"""
class ChunkedUploader:
"""Run the prepare → PUT parts → complete sequence.
:param api_request: Bound ``_api_request(method, path, body=..., timeout=...)``
coroutine from the adapter. Must raise ``RuntimeError`` with the biz_code
embedded in the message on API errors.
:param http_put: Coroutine ``(url, data, headers, timeout) -> response`` for
COS part uploads. Typically wraps ``httpx.AsyncClient.put``.
:param log_tag: Log prefix.
"""
def __init__(
self,
api_request: ApiRequestFn,
http_put: Callable[..., Awaitable[Any]],
log_tag: str = "QQBot",
) -> None:
self._api_request = api_request
self._http_put = http_put
self._log_tag = log_tag
async def upload(
self,
chat_type: str,
target_id: str,
file_path: str,
file_type: int,
file_name: str,
) -> Dict[str, Any]:
"""Run the full chunked upload and return the ``complete_upload`` response.
:param chat_type: ``'c2c'`` or ``'group'``.
:param target_id: User or group openid.
:param file_path: Absolute path to a local file.
:param file_type: ``MEDIA_TYPE_*`` constant.
:param file_name: Original filename (for upload_prepare).
:returns: The raw response dict from ``complete_upload`` — contains
``file_info`` that the caller uses in a RichMedia message body.
:raises UploadDailyLimitExceededError: On biz_code 40093002.
:raises UploadFileTooLargeError: When the file exceeds the platform limit.
:raises RuntimeError: On other API or I/O failures.
"""
if chat_type not in {"c2c", "group"}:
raise ValueError(
f"ChunkedUploader: unsupported chat_type {chat_type!r}"
)
path = Path(file_path)
file_size = path.stat().st_size
logger.info(
"[%s] Chunked upload start: file=%s size=%s type=%d",
self._log_tag, file_name, format_size(file_size), file_type,
)
# Step 1: compute hashes (blocking I/O → executor).
hashes = await asyncio.get_running_loop().run_in_executor(
None, _compute_file_hashes, file_path, file_size
)
# Step 2: upload_prepare.
prepare = await self._prepare(
chat_type, target_id, file_type, file_name, file_size, hashes
)
max_concurrent = min(prepare.concurrency, _MAX_CONCURRENT_PARTS)
retry_timeout = min(
prepare.retry_timeout if prepare.retry_timeout > 0 else _PART_FINISH_DEFAULT_TIMEOUT,
_PART_FINISH_MAX_TIMEOUT,
)
logger.info(
"[%s] Prepared: upload_id=%s block_size=%s parts=%d concurrency=%d",
self._log_tag, prepare.upload_id, format_size(prepare.block_size),
len(prepare.parts), max_concurrent,
)
progress = _UploadProgress(
total_parts=len(prepare.parts),
total_bytes=file_size,
)
# Step 3: PUT each part + notify.
tasks: List[Callable[[], Awaitable[None]]] = [
functools.partial(
self._upload_one_part,
chat_type=chat_type,
target_id=target_id,
file_path=file_path,
file_size=file_size,
upload_id=prepare.upload_id,
rsp_block_size=prepare.block_size,
part=part,
retry_timeout=retry_timeout,
progress=progress,
)
for part in prepare.parts
]
await _run_with_concurrency(tasks, max_concurrent)
logger.info(
"[%s] All %d parts uploaded, completing…",
self._log_tag, len(prepare.parts),
)
# Step 4: complete_upload (retry on transient errors).
return await self._complete(chat_type, target_id, prepare.upload_id)
# ──────────────────────────────────────────────────────────────────
# Step 1 — upload_prepare
# ──────────────────────────────────────────────────────────────────
async def _prepare(
self,
chat_type: str,
target_id: str,
file_type: int,
file_name: str,
file_size: int,
hashes: Dict[str, str],
) -> _PrepareResult:
base = "/v2/users" if chat_type == "c2c" else "/v2/groups"
path = f"{base}/{target_id}/upload_prepare"
body = {
"file_type": file_type,
"file_name": file_name,
"file_size": file_size,
"md5": hashes["md5"],
"sha1": hashes["sha1"],
"md5_10m": hashes["md5_10m"],
}
try:
raw = await self._api_request(
"POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT
)
except RuntimeError as exc:
err_msg = str(exc)
if f"{_BIZ_CODE_DAILY_LIMIT}" in err_msg:
raise UploadDailyLimitExceededError(
file_name, file_size, err_msg
) from exc
raise
return _parse_prepare_response(raw)
# ──────────────────────────────────────────────────────────────────
# Step 2 — PUT one part + part_finish
# ──────────────────────────────────────────────────────────────────
async def _upload_one_part(
self,
chat_type: str,
target_id: str,
file_path: str,
file_size: int,
upload_id: str,
rsp_block_size: int,
part: _PreparePart,
retry_timeout: float,
progress: _UploadProgress,
) -> None:
"""PUT one part to COS, then call ``upload_part_finish``."""
part_index = part.index
# Per-part block_size wins; fall back to the response-level value.
actual_block_size = part.block_size if part.block_size > 0 else rsp_block_size
offset = (part_index - 1) * rsp_block_size
length = min(actual_block_size, file_size - offset)
# Read this slice of the file (blocking → executor).
data = await asyncio.get_running_loop().run_in_executor(
None, _read_file_chunk, file_path, offset, length
)
md5_hex = hashlib.md5(data).hexdigest()
logger.debug(
"[%s] Part %d/%d: uploading %s (offset=%d md5=%s)",
self._log_tag, part_index, progress.total_parts,
format_size(length), offset, md5_hex,
)
await self._put_to_presigned_url(
part.presigned_url, data, part_index, progress.total_parts
)
await self._part_finish_with_retry(
chat_type, target_id, upload_id,
part_index, length, md5_hex, retry_timeout,
)
progress.completed_parts += 1
progress.uploaded_bytes += length
logger.debug(
"[%s] Part %d/%d done (%d/%d total)",
self._log_tag, part_index, progress.total_parts,
progress.completed_parts, progress.total_parts,
)
async def _put_to_presigned_url(
self,
url: str,
data: bytes,
part_index: int,
total_parts: int,
) -> None:
"""PUT part data to a pre-signed COS URL with retry."""
last_exc: Optional[Exception] = None
for attempt in range(_PART_UPLOAD_MAX_RETRIES + 1):
try:
resp = await asyncio.wait_for(
self._http_put(
url,
data=data,
headers={"Content-Length": str(len(data))},
),
timeout=_PART_UPLOAD_TIMEOUT,
)
# Caller's http_put is expected to return an httpx-like response.
status = getattr(resp, "status_code", 0)
if 200 <= status < 300:
logger.debug(
"[%s] PUT part %d/%d: %d OK",
self._log_tag, part_index, total_parts, status,
)
return
body_preview = ""
try:
body_preview = getattr(resp, "text", "")[:200]
except Exception: # pragma: no cover — defensive
pass
raise RuntimeError(
f"COS PUT returned {status}: {body_preview}"
)
except Exception as exc:
last_exc = exc
if attempt < _PART_UPLOAD_MAX_RETRIES:
delay = 1.0 * (2 ** attempt)
logger.warning(
"[%s] PUT part %d/%d attempt %d failed, retry in %.1fs: %s",
self._log_tag, part_index, total_parts,
attempt + 1, delay, exc,
)
await asyncio.sleep(delay)
raise RuntimeError(
f"Part {part_index}/{total_parts} upload failed after "
f"{_PART_UPLOAD_MAX_RETRIES + 1} attempts: {last_exc}"
)
async def _part_finish_with_retry(
self,
chat_type: str,
target_id: str,
upload_id: str,
part_index: int,
block_size: int,
md5: str,
retry_timeout: float,
) -> None:
"""Call ``upload_part_finish``, retrying on biz_code 40093001."""
base = "/v2/users" if chat_type == "c2c" else "/v2/groups"
path = f"{base}/{target_id}/upload_part_finish"
body = {
"upload_id": upload_id,
"part_index": part_index,
"block_size": block_size,
"md5": md5,
}
loop = asyncio.get_running_loop()
start = loop.time()
attempt = 0
while True:
try:
await self._api_request(
"POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT
)
return
except RuntimeError as exc:
err_msg = str(exc)
if f"{_BIZ_CODE_PART_RETRYABLE}" not in err_msg:
raise
elapsed = loop.time() - start
if elapsed >= retry_timeout:
raise RuntimeError(
f"upload_part_finish persistent retry timed out "
f"after {retry_timeout:.0f}s ({attempt} retries): {exc}"
) from exc
attempt += 1
logger.debug(
"[%s] part_finish retryable error, attempt %d, "
"elapsed=%.1fs: %s",
self._log_tag, attempt, elapsed, exc,
)
await asyncio.sleep(_PART_FINISH_RETRY_INTERVAL)
# ──────────────────────────────────────────────────────────────────
# Step 3 — complete_upload
# ──────────────────────────────────────────────────────────────────
async def _complete(
self,
chat_type: str,
target_id: str,
upload_id: str,
) -> Dict[str, Any]:
"""Call ``complete_upload`` with retry.
This reuses the ``/files`` endpoint (same as the simple URL-based upload)
but signals the chunked-completion path by sending only ``upload_id``.
"""
base = "/v2/users" if chat_type == "c2c" else "/v2/groups"
path = f"{base}/{target_id}/files"
body = {"upload_id": upload_id}
last_exc: Optional[Exception] = None
for attempt in range(_COMPLETE_UPLOAD_MAX_RETRIES + 1):
try:
return await self._api_request(
"POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT
)
except Exception as exc:
last_exc = exc
if attempt < _COMPLETE_UPLOAD_MAX_RETRIES:
delay = _COMPLETE_UPLOAD_BASE_DELAY * (2 ** attempt)
logger.warning(
"[%s] complete_upload attempt %d failed, "
"retry in %.1fs: %s",
self._log_tag, attempt + 1, delay, exc,
)
await asyncio.sleep(delay)
raise RuntimeError(
f"complete_upload failed after "
f"{_COMPLETE_UPLOAD_MAX_RETRIES + 1} attempts: {last_exc}"
)
# ── Helpers (module-level for testability) ───────────────────────────
def format_size(size_bytes: int) -> str:
"""Return a human-readable file size string (e.g. ``'12.3 MB'``)."""
size = float(size_bytes)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TB"
def _read_file_chunk(file_path: str, offset: int, length: int) -> bytes:
"""Read *length* bytes from *file_path* starting at *offset*.
:raises IOError: If fewer bytes were read than expected (truncated file).
"""
with open(file_path, "rb") as fh:
fh.seek(offset)
data = fh.read(length)
if len(data) != length:
raise IOError(
f"Short read from {file_path}: expected {length} bytes at "
f"offset {offset}, got {len(data)} (file may be truncated)"
)
return data
def _compute_file_hashes(file_path: str, file_size: int) -> Dict[str, str]:
"""Compute md5, sha1, and md5_10m in a single pass."""
md5 = hashlib.md5()
sha1 = hashlib.sha1()
md5_10m = hashlib.md5()
need_10m = file_size > _MD5_10M_SIZE
bytes_read = 0
with open(file_path, "rb") as fh:
while True:
chunk = fh.read(65536)
if not chunk:
break
md5.update(chunk)
sha1.update(chunk)
if need_10m:
remaining = _MD5_10M_SIZE - bytes_read
if remaining > 0:
md5_10m.update(chunk[:remaining])
bytes_read += len(chunk)
full_md5 = md5.hexdigest()
return {
"md5": full_md5,
"sha1": sha1.hexdigest(),
# For small files the "10m" hash is just the full md5.
"md5_10m": md5_10m.hexdigest() if need_10m else full_md5,
}
async def _run_with_concurrency(
tasks: List[Callable[[], Awaitable[None]]],
concurrency: int,
) -> None:
"""Run a list of thunks with a bounded number in flight at once."""
concurrency = max(concurrency, 1)
sem = asyncio.Semaphore(concurrency)
async def _wrap(thunk: Callable[[], Awaitable[None]]) -> None:
async with sem:
await thunk()
await asyncio.gather(*(_wrap(t) for t in tasks))
+74
View File
@@ -0,0 +1,74 @@
"""QQBot package-level constants shared across adapter, onboard, and other modules."""
from __future__ import annotations
import os
# ---------------------------------------------------------------------------
# QQBot adapter version — bump on functional changes to the adapter package.
# ---------------------------------------------------------------------------
QQBOT_VERSION = "1.1.0"
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
# The portal domain is configurable via QQ_API_HOST for corporate proxies
# or test environments. Default: q.qq.com (production).
PORTAL_HOST = os.getenv("QQ_PORTAL_HOST", "q.qq.com")
API_BASE = "https://api.sgroup.qq.com"
TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"
GATEWAY_URL_PATH = "/gateway"
# QR-code onboard endpoints (on the portal host)
ONBOARD_CREATE_PATH = "/lite/create_bind_task"
ONBOARD_POLL_PATH = "/lite/poll_bind_result"
QR_URL_TEMPLATE = (
"https://q.qq.com/qqbot/openclaw/connect.html"
"?task_id={task_id}&_wv=2&source=hermes"
)
# ---------------------------------------------------------------------------
# Timeouts & retry
# ---------------------------------------------------------------------------
DEFAULT_API_TIMEOUT = 30.0
FILE_UPLOAD_TIMEOUT = 120.0
CONNECT_TIMEOUT_SECONDS = 20.0
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
MAX_RECONNECT_ATTEMPTS = 100
RATE_LIMIT_DELAY = 60 # seconds
QUICK_DISCONNECT_THRESHOLD = 5.0 # seconds
MAX_QUICK_DISCONNECT_COUNT = 3
ONBOARD_POLL_INTERVAL = 2.0 # seconds between poll_bind_result calls
ONBOARD_API_TIMEOUT = 10.0
# ---------------------------------------------------------------------------
# Message limits
# ---------------------------------------------------------------------------
MAX_MESSAGE_LENGTH = 4000
DEDUP_WINDOW_SECONDS = 300
DEDUP_MAX_SIZE = 1000
# ---------------------------------------------------------------------------
# QQ Bot message types
# ---------------------------------------------------------------------------
MSG_TYPE_TEXT = 0
MSG_TYPE_MARKDOWN = 2
MSG_TYPE_MEDIA = 7
MSG_TYPE_INPUT_NOTIFY = 6
# ---------------------------------------------------------------------------
# QQ Bot file media types
# ---------------------------------------------------------------------------
MEDIA_TYPE_IMAGE = 1
MEDIA_TYPE_VIDEO = 2
MEDIA_TYPE_VOICE = 3
MEDIA_TYPE_FILE = 4
+45
View File
@@ -0,0 +1,45 @@
"""AES-256-GCM utilities for QQBot scan-to-configure credential decryption."""
from __future__ import annotations
import base64
import os
def generate_bind_key() -> str:
"""Generate a 256-bit random AES key and return it as base64.
The key is passed to ``create_bind_task`` so the server can encrypt
the bot's *client_secret* before returning it. Only this CLI holds
the key, ensuring the secret never travels in plaintext.
"""
return base64.b64encode(os.urandom(32)).decode()
def decrypt_secret(encrypted_base64: str, key_base64: str) -> str:
"""Decrypt a base64-encoded AES-256-GCM ciphertext.
Ciphertext layout (after base64-decoding)::
IV (12 bytes) ‖ ciphertext (N bytes) ‖ AuthTag (16 bytes)
Args:
encrypted_base64: The ``bot_encrypt_secret`` value from
``poll_bind_result``.
key_base64: The base64 AES key generated by
:func:`generate_bind_key`.
Returns:
The decrypted *client_secret* as a UTF-8 string.
"""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = base64.b64decode(key_base64)
raw = base64.b64decode(encrypted_base64)
iv = raw[:12]
ciphertext_with_tag = raw[12:] # AESGCM expects ciphertext + tag concatenated
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(iv, ciphertext_with_tag, None)
return plaintext.decode("utf-8")
+461
View File
@@ -0,0 +1,461 @@
"""QQ Bot inline keyboards + approval / update-prompt senders.
QQ Bot v2 supports attaching inline keyboards to outbound messages. When a
user clicks a button, the platform dispatches an ``INTERACTION_CREATE``
gateway event containing the button's ``data`` payload. The bot must ACK the
interaction promptly via ``PUT /interactions/{id}`` or the user sees an
error indicator on the button.
This module provides:
- :class:`InlineKeyboard` + button dataclasses — serialized into the
``keyboard`` field of the outbound message body.
- :func:`build_approval_keyboard` — 3-button ✅ once / ⭐ always / ❌ deny
keyboard for tool-approval flows.
- :func:`build_update_prompt_keyboard` — Yes/No keyboard for update confirms.
- :func:`parse_approval_button_data` / :func:`parse_update_prompt_button_data`
— decode the ``button_data`` payload from ``INTERACTION_CREATE``.
- :class:`ApprovalRequest` + :class:`ApprovalSender` — high-level helper that
builds an approval message with keyboard and posts it to a c2c / group chat.
``button_data`` formats::
approve:<session_key>:<decision> # decision = allow-once|allow-always|deny
update_prompt:<answer> # answer = y|n
Ported from WideLee's qqbot-agent-sdk v1.2.2 (``approval.py`` + ``dto.py``
keyboard types). Authorship preserved via Co-authored-by.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
# ── button_data prefixes + patterns ──────────────────────────────────
APPROVAL_BUTTON_PREFIX = "approve:"
UPDATE_PROMPT_PREFIX = "update_prompt:"
# Pattern: approve:<session_key>:<decision>
# session_key may itself contain colons (e.g. agent:main:qqbot:c2c:OPENID),
# so the session_key group is greedy but trails the decision.
_APPROVAL_DATA_RE = re.compile(
r"^approve:(.+):(allow-once|allow-always|deny)$"
)
# Pattern: update_prompt:y | update_prompt:n
_UPDATE_PROMPT_RE = re.compile(r"^update_prompt:(y|n)$")
# ── Keyboard dataclasses ─────────────────────────────────────────────
@dataclass
class KeyboardButtonPermission:
"""Button permission metadata. ``type=2`` means all users can click."""
type: int = 2
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type}
@dataclass
class KeyboardButtonAction:
"""What happens when the button is clicked.
:param type: ``1`` (Callback — triggers ``INTERACTION_CREATE``) or
``2`` (Link — opens a URL).
:param data: Payload delivered in ``data.resolved.button_data`` when
``type=1``.
:param permission: :class:`KeyboardButtonPermission`.
:param click_limit: Max clicks per user (``1`` = single-use).
"""
type: int
data: str
permission: KeyboardButtonPermission = field(
default_factory=KeyboardButtonPermission
)
click_limit: int = 1
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"data": self.data,
"permission": self.permission.to_dict(),
"click_limit": self.click_limit,
}
@dataclass
class KeyboardButtonRenderData:
"""Visual rendering of a button.
:param label: Pre-click label.
:param visited_label: Post-click label (button stays greyed in place).
:param style: ``0`` = grey, ``1`` = blue.
"""
label: str
visited_label: str
style: int = 1
def to_dict(self) -> Dict[str, Any]:
return {
"label": self.label,
"visited_label": self.visited_label,
"style": self.style,
}
@dataclass
class KeyboardButton:
"""One button in a keyboard.
:param group_id: Buttons sharing a ``group_id`` are mutually exclusive —
clicking one greys the rest.
"""
id: str
render_data: KeyboardButtonRenderData
action: KeyboardButtonAction
group_id: str = "default"
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"render_data": self.render_data.to_dict(),
"action": self.action.to_dict(),
"group_id": self.group_id,
}
@dataclass
class KeyboardRow:
buttons: List[KeyboardButton] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {"buttons": [b.to_dict() for b in self.buttons]}
@dataclass
class KeyboardContent:
rows: List[KeyboardRow] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {"rows": [r.to_dict() for r in self.rows]}
@dataclass
class InlineKeyboard:
"""Top-level keyboard payload — goes into ``MessageToCreate.keyboard``."""
content: KeyboardContent = field(default_factory=KeyboardContent)
def to_dict(self) -> Dict[str, Any]:
return {"content": self.content.to_dict()}
# ── INTERACTION_CREATE parsing ───────────────────────────────────────
def parse_approval_button_data(button_data: str) -> Optional[tuple[str, str]]:
"""Parse approval ``button_data`` into ``(session_key, decision)``.
:param button_data: Raw ``data.resolved.button_data`` from
``INTERACTION_CREATE``.
:returns: ``(session_key, decision)`` or ``None`` if not an approval button.
"""
m = _APPROVAL_DATA_RE.match(button_data or "")
if not m:
return None
return m.group(1), m.group(2)
def parse_update_prompt_button_data(button_data: str) -> Optional[str]:
"""Parse update-prompt ``button_data`` into ``'y'`` or ``'n'``."""
m = _UPDATE_PROMPT_RE.match(button_data or "")
if not m:
return None
return m.group(1)
# ── Keyboard builders ────────────────────────────────────────────────
def _make_callback_button(
btn_id: str,
label: str,
visited_label: str,
data: str,
style: int,
group_id: str,
) -> KeyboardButton:
return KeyboardButton(
id=btn_id,
render_data=KeyboardButtonRenderData(
label=label,
visited_label=visited_label,
style=style,
),
action=KeyboardButtonAction(type=1, data=data),
group_id=group_id,
)
def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) -> InlineKeyboard:
"""Build the approval keyboard, hiding persistent scope when unavailable.
Layout: ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` — all three share
``group_id='approval'`` so clicking one greys out the rest.
:param session_key: Embedded into ``button_data`` so the decision
routes back to the right pending approval.
"""
buttons = [
_make_callback_button(
btn_id="allow", label="✅ 允许一次", visited_label="已允许",
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-once",
style=1, group_id="approval",
)
]
if allow_permanent:
buttons.append(_make_callback_button(
btn_id="always", label="⭐ 始终允许", visited_label="已始终允许",
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-always",
style=1, group_id="approval",
))
buttons.append(_make_callback_button(
btn_id="deny", label="❌ 拒绝", visited_label="已拒绝",
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:deny",
style=0, group_id="approval",
))
return InlineKeyboard(content=KeyboardContent(rows=[KeyboardRow(buttons=buttons)]))
def build_update_prompt_keyboard() -> InlineKeyboard:
"""Build a Yes/No keyboard for update confirmation prompts."""
return InlineKeyboard(
content=KeyboardContent(
rows=[
KeyboardRow(buttons=[
_make_callback_button(
btn_id="yes",
label="✓ 确认",
visited_label="已确认",
data=f"{UPDATE_PROMPT_PREFIX}y",
style=1,
group_id="update_prompt",
),
_make_callback_button(
btn_id="no",
label="✗ 取消",
visited_label="已取消",
data=f"{UPDATE_PROMPT_PREFIX}n",
style=0,
group_id="update_prompt",
),
]),
]
)
)
# ── ApprovalRequest + text builder ───────────────────────────────────
@dataclass
class ApprovalRequest:
"""Structured approval-request display data.
:param session_key: Routes the decision back to the waiting caller.
:param title: Short title at the top.
:param description: Optional longer description.
:param command_preview: Command text (exec approvals).
:param cwd: Working directory (exec approvals).
:param tool_name: Tool name (plugin approvals).
:param severity: ``'critical' | 'info' | ''``.
:param timeout_sec: Seconds until the approval expires.
"""
session_key: str
title: str
description: str = ""
command_preview: str = ""
cwd: str = ""
tool_name: str = ""
severity: str = ""
timeout_sec: int = 120
allow_permanent: bool = True
def build_approval_text(req: ApprovalRequest) -> str:
"""Render an :class:`ApprovalRequest` into the message body (markdown)."""
if req.command_preview or req.cwd:
return _build_exec_text(req)
return _build_plugin_text(req)
def _build_exec_text(req: ApprovalRequest) -> str:
lines: List[str] = ["🔐 **命令执行审批**", ""]
if req.command_preview:
preview = req.command_preview[:300]
lines.append(f"```\n{preview}\n```")
if req.cwd:
lines.append(f"📁 目录: {req.cwd}")
if req.title and req.title != req.command_preview:
lines.append(f"📋 {req.title}")
if req.description:
lines.append(f"📝 {req.description}")
lines.append("")
lines.append(f"⏱️ 超时: {req.timeout_sec} 秒")
return "\n".join(lines)
def _build_plugin_text(req: ApprovalRequest) -> str:
icon = (
"🔴" if req.severity == "critical"
else "🔵" if req.severity == "info"
else "🟡"
)
lines: List[str] = [f"{icon} **审批请求**", ""]
lines.append(f"📋 {req.title}")
if req.description:
lines.append(f"📝 {req.description}")
if req.tool_name:
lines.append(f"🔧 工具: {req.tool_name}")
lines.append("")
lines.append(f"⏱️ 超时: {req.timeout_sec} 秒")
return "\n".join(lines)
# ── ApprovalSender ───────────────────────────────────────────────────
PostMessageFn = Callable[..., Awaitable[Dict[str, Any]]]
"""Signature of an async POST to ``/v2/{users|groups}/{id}/messages``.
Implementations accept a body dict and return the raw API response.
"""
class ApprovalSender:
"""Send an approval-request message with an inline keyboard.
Decoupled from the adapter via callables so it can be unit-tested in
isolation. Pass the adapter's ``_send_message_with_keyboard`` helper
(or any equivalent) as ``post_message``.
"""
def __init__(
self,
post_c2c: PostMessageFn,
post_group: PostMessageFn,
log_tag: str = "QQBot",
) -> None:
self._post_c2c = post_c2c
self._post_group = post_group
self._log_tag = log_tag
async def send(
self,
chat_type: str,
chat_id: str,
req: ApprovalRequest,
msg_id: Optional[str] = None,
) -> bool:
"""Send an approval message to *chat_id*.
:param chat_type: ``'c2c'`` or ``'group'``.
:param chat_id: User openid or group openid.
:param req: :class:`ApprovalRequest`.
:param msg_id: Reply-to message id (required for passive messages).
:returns: ``True`` on success, ``False`` on failure.
"""
text = build_approval_text(req)
keyboard = build_approval_keyboard(req.session_key)
logger.info(
"[%s] Sending approval request to %s:%s (session=%.20s…)",
self._log_tag, chat_type, chat_id, req.session_key,
)
try:
if chat_type == "c2c":
await self._post_c2c(chat_id, text, msg_id, keyboard)
elif chat_type == "group":
await self._post_group(chat_id, text, msg_id, keyboard)
else:
logger.warning(
"[%s] Approval: unsupported chat_type %r",
self._log_tag, chat_type,
)
return False
logger.info(
"[%s] Approval message sent to %s:%s",
self._log_tag, chat_type, chat_id,
)
return True
except Exception as exc:
logger.error(
"[%s] Failed to send approval message to %s:%s: %s",
self._log_tag, chat_type, chat_id, exc,
)
return False
# ── INTERACTION_CREATE event shape ───────────────────────────────────
@dataclass
class InteractionEvent:
"""Parsed ``INTERACTION_CREATE`` event payload.
See https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
"""
id: str = ""
"""Interaction event id — required for the ``PUT /interactions/{id}`` ACK."""
type: int = 0
"""Event type code (``11`` = message button)."""
chat_type: int = 0
"""``0`` = guild, ``1`` = group, ``2`` = c2c."""
scene: str = ""
"""``'guild'`` | ``'group'`` | ``'c2c'`` — human-readable scene."""
group_openid: str = ""
group_member_openid: str = ""
user_openid: str = ""
channel_id: str = ""
guild_id: str = ""
button_data: str = ""
button_id: str = ""
resolver_user_id: str = ""
@property
def operator_openid(self) -> str:
"""Best available operator openid (group → member; c2c → user)."""
return (
self.group_member_openid
or self.user_openid
or self.resolver_user_id
)
def parse_interaction_event(raw: Dict[str, Any]) -> InteractionEvent:
"""Parse a raw ``INTERACTION_CREATE`` dispatch payload (``d``)."""
data_raw = raw.get("data") or {}
resolved = data_raw.get("resolved") or {}
scene_code = int(raw.get("chat_type", 0) or 0)
scene = {0: "guild", 1: "group", 2: "c2c"}.get(scene_code, "")
return InteractionEvent(
id=str(raw.get("id", "")),
type=int(data_raw.get("type", 0) or 0),
chat_type=scene_code,
scene=scene,
group_openid=str(raw.get("group_openid", "")),
group_member_openid=str(raw.get("group_member_openid", "")),
user_openid=str(raw.get("user_openid", "")),
channel_id=str(raw.get("channel_id", "")),
guild_id=str(raw.get("guild_id", "")),
button_data=str(resolved.get("button_data", "")),
button_id=str(resolved.get("button_id", "")),
resolver_user_id=str(resolved.get("user_id", "")),
)
+220
View File
@@ -0,0 +1,220 @@
"""
QQBot scan-to-configure (QR code onboard) module.
Mirrors the Feishu onboarding pattern: synchronous HTTP + a single public
entry-point ``qr_register()`` that handles the full flow (create task →
display QR code → poll → decrypt credentials).
Calls the ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result`` APIs to
generate a QR-code URL and poll for scan completion. On success the caller
receives the bot's *app_id*, *client_secret* (decrypted locally), and the
scanner's *user_openid* — enough to fully configure the QQBot gateway.
Reference: https://bot.q.qq.com/wiki/develop/api-v2/
"""
from __future__ import annotations
import logging
import time
from enum import IntEnum
from typing import Optional, Tuple
from urllib.parse import quote
from .constants import (
ONBOARD_API_TIMEOUT,
ONBOARD_CREATE_PATH,
ONBOARD_POLL_INTERVAL,
ONBOARD_POLL_PATH,
PORTAL_HOST,
QR_URL_TEMPLATE,
)
from .crypto import decrypt_secret, generate_bind_key
from .utils import get_api_headers
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Bind status
# ---------------------------------------------------------------------------
class BindStatus(IntEnum):
"""Status codes returned by ``_poll_bind_result``."""
NONE = 0
PENDING = 1
COMPLETED = 2
EXPIRED = 3
# ---------------------------------------------------------------------------
# QR rendering
# ---------------------------------------------------------------------------
try:
import qrcode as _qrcode_mod
except (ImportError, TypeError):
_qrcode_mod = None # type: ignore[assignment]
def _render_qr(url: str) -> bool:
"""Try to render a QR code in the terminal. Returns True if successful."""
if _qrcode_mod is None:
return False
try:
qr = _qrcode_mod.QRCode(
error_correction=_qrcode_mod.constants.ERROR_CORRECT_M,
border=2,
)
qr.add_data(url)
qr.make(fit=True)
qr.print_ascii(invert=True)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# Synchronous HTTP helpers (mirrors Feishu _post_registration pattern)
# ---------------------------------------------------------------------------
def _create_bind_task(timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[str, str]:
"""Create a bind task and return *(task_id, aes_key_base64)*.
Raises:
RuntimeError: If the API returns a non-zero ``retcode``.
"""
import httpx
url = f"https://{PORTAL_HOST}{ONBOARD_CREATE_PATH}"
key = generate_bind_key()
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
resp = client.post(url, json={"key": key}, headers=get_api_headers())
resp.raise_for_status()
data = resp.json()
if data.get("retcode") != 0:
raise RuntimeError(data.get("msg", "create_bind_task failed"))
task_id = (data.get("data") or {}).get("task_id")
if not task_id:
raise RuntimeError("create_bind_task: missing task_id in response")
logger.debug("create_bind_task ok: task_id=%s", task_id)
return task_id, key
def _poll_bind_result(
task_id: str,
timeout: float = ONBOARD_API_TIMEOUT,
) -> Tuple[BindStatus, str, str, str]:
"""Poll the bind result for *task_id*.
Returns:
A 4-tuple of ``(status, bot_appid, bot_encrypt_secret, user_openid)``.
Raises:
RuntimeError: If the API returns a non-zero ``retcode``.
"""
import httpx
url = f"https://{PORTAL_HOST}{ONBOARD_POLL_PATH}"
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
resp = client.post(url, json={"task_id": task_id}, headers=get_api_headers())
resp.raise_for_status()
data = resp.json()
if data.get("retcode") != 0:
raise RuntimeError(data.get("msg", "poll_bind_result failed"))
d = data.get("data", {})
return (
BindStatus(d.get("status", 0)),
str(d.get("bot_appid", "")),
d.get("bot_encrypt_secret", ""),
d.get("user_openid", ""),
)
def build_connect_url(task_id: str) -> str:
"""Build the QR-code target URL for a given *task_id*."""
return QR_URL_TEMPLATE.format(task_id=quote(task_id))
# ---------------------------------------------------------------------------
# Public entry-point
# ---------------------------------------------------------------------------
_MAX_REFRESHES = 3
def qr_register(timeout_seconds: int = 600) -> Optional[dict]:
"""Run the QQBot scan-to-configure QR registration flow.
Mirrors ``feishu.qr_register()``: handles create → display → poll →
decrypt in one call. Unexpected errors propagate to the caller.
:returns:
``{"app_id": ..., "client_secret": ..., "user_openid": ...}`` on
success, or ``None`` on failure / expiry / cancellation.
"""
deadline = time.monotonic() + timeout_seconds
for refresh_count in range(_MAX_REFRESHES + 1):
# ── Create bind task ──
try:
task_id, aes_key = _create_bind_task()
except Exception as exc:
logger.warning("[QQBot onboard] Failed to create bind task: %s", exc)
return None
url = build_connect_url(task_id)
# ── Display QR code + URL ──
print()
if _render_qr(url):
print(f" Scan the QR code above, or open this URL directly:\n {url}")
else:
print(f" Open this URL in QQ on your phone:\n {url}")
print(" Tip: pip install qrcode to display a scannable QR code here")
print()
# ── Poll loop ──
while time.monotonic() < deadline:
try:
status, app_id, encrypted_secret, user_openid = _poll_bind_result(task_id)
except Exception:
time.sleep(ONBOARD_POLL_INTERVAL)
continue
if status == BindStatus.COMPLETED:
client_secret = decrypt_secret(encrypted_secret, aes_key)
print()
print(f" QR scan complete! (App ID: {app_id})")
if user_openid:
print(f" Scanner's OpenID: {user_openid}")
return {
"app_id": app_id,
"client_secret": client_secret,
"user_openid": user_openid,
}
if status == BindStatus.EXPIRED:
if refresh_count >= _MAX_REFRESHES:
logger.warning("[QQBot onboard] QR code expired %d times — giving up", _MAX_REFRESHES)
return None
print(f"\n QR code expired, refreshing... ({refresh_count + 1}/{_MAX_REFRESHES})")
break # next for-loop iteration creates a new task
time.sleep(ONBOARD_POLL_INTERVAL)
else:
# deadline reached without completing
logger.warning("[QQBot onboard] Poll timed out after %ds", timeout_seconds)
return None
return None
+71
View File
@@ -0,0 +1,71 @@
"""QQBot shared utilities — User-Agent, HTTP helpers, config coercion."""
from __future__ import annotations
import platform
import sys
from typing import Any, Dict, List
from .constants import QQBOT_VERSION
# ---------------------------------------------------------------------------
# User-Agent
# ---------------------------------------------------------------------------
def _get_hermes_version() -> str:
"""Return the hermes-agent package version, or 'dev' if unavailable."""
try:
from importlib.metadata import version
return version("hermes-agent")
except Exception:
return "dev"
def build_user_agent() -> str:
"""Build a descriptive User-Agent string.
Format::
QQBotAdapter/<qqbot_version> (Python/<py_version>; <os>; Hermes/<hermes_version>)
Example::
QQBotAdapter/1.0.0 (Python/3.11.15; darwin; Hermes/0.9.0)
"""
py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
os_name = platform.system().lower()
hermes_version = _get_hermes_version()
return f"QQBotAdapter/{QQBOT_VERSION} (Python/{py_version}; {os_name}; Hermes/{hermes_version})"
def get_api_headers() -> Dict[str, str]:
"""Return standard HTTP headers for QQBot API requests.
Includes ``Content-Type``, ``Accept``, and a dynamic ``User-Agent``.
``q.qq.com`` requires ``Accept: application/json`` — without it,
the server returns a JavaScript anti-bot challenge page.
"""
return {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": build_user_agent(),
}
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
def coerce_list(value: Any) -> List[str]:
"""Coerce config values into a trimmed string list.
Accepts comma-separated strings, lists, tuples, sets, or single values.
"""
if value is None:
return []
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
return [str(value).strip()] if str(value).strip() else []
File diff suppressed because it is too large. Load diff
+140
View File
@@ -0,0 +1,140 @@
"""Shared Signal formatting helpers.
Keep markdown → Signal native formatting conversion in one place so both the
live Signal adapter and standalone send paths emit the same bodyRanges.
"""
from __future__ import annotations
import re
def markdown_to_signal(text: str) -> tuple[str, list[str]]:
"""Convert markdown to plain text + Signal textStyles list.
Signal doesn't render markdown. Instead it uses ``bodyRanges`` (exposed by
signal-cli as ``textStyle`` / ``textStyles`` params) with the format
``start:length:STYLE``.
Positions are measured in UTF-16 code units because that's what the Signal
protocol uses.
Supported styles: BOLD, ITALIC, STRIKETHROUGH, MONOSPACE.
"""
def _utf16_len(s: str) -> int:
"""Length of *s* in UTF-16 code units."""
return len(s.encode("utf-16-le")) // 2
def _normalize_bullet_markers(source: str) -> str:
"""Replace Markdown bullet markers with plain Unicode bullets.
Signal does not render Markdown list syntax, so ``- item`` and
``* item`` otherwise arrive as literal Markdown markers. Preserve
fenced code blocks byte-for-byte; list-looking lines inside code are
code, not prose bullets.
"""
parts = re.split(r"(```.*?```)", source, flags=re.DOTALL)
for idx, part in enumerate(parts):
if idx % 2 == 1:
continue
parts[idx] = re.sub(r"(?m)^([ \t]{0,3})[-*+]\s+", r"\1• ", part)
return "".join(parts)
text = re.sub(r"\n{3,}", "\n\n", text)
text = text.strip()
text = _normalize_bullet_markers(text)
styles: list[tuple[int, int, str]] = []
code_block = re.compile(r"```[a-zA-Z0-9_+-]*\n?(.*?)```", re.DOTALL)
while match := code_block.search(text):
inner = match.group(1).rstrip("\n")
start = match.start()
text = text[: match.start()] + inner + text[match.end() :]
styles.append((start, len(inner), "MONOSPACE"))
heading = re.compile(r"^#{1,6}\s+", re.MULTILINE)
new_text = ""
last_end = 0
for match in heading.finditer(text):
new_text += text[last_end : match.start()]
last_end = match.end()
eol = text.find("\n", match.end())
if eol == -1:
eol = len(text)
heading_text = text[match.end() : eol]
start = len(new_text)
new_text += heading_text
styles.append((start, len(heading_text), "BOLD"))
last_end = eol
new_text += text[last_end:]
text = new_text
patterns = [
(re.compile(r"\*\*(.+?)\*\*", re.DOTALL), "BOLD"),
(re.compile(r"__(.+?)__", re.DOTALL), "BOLD"),
(re.compile(r"~~(.+?)~~", re.DOTALL), "STRIKETHROUGH"),
(re.compile(r"`(.+?)`"), "MONOSPACE"),
(re.compile(r"(?<!\*)\*(?!\*| )(.+?)(?<!\*)\*(?!\*)"), "ITALIC"),
(re.compile(r"(?<!\w)_(?!_)(.+?)(?<!_)_(?!\w)"), "ITALIC"),
]
all_matches: list[tuple[int, int, int, int, str]] = []
occupied: list[tuple[int, int]] = []
for pattern, style in patterns:
for match in pattern.finditer(text):
ms, me = match.start(), match.end()
if not any(ms < oe and me > os for os, oe in occupied):
all_matches.append((ms, me, match.start(1), match.end(1), style))
occupied.append((ms, me))
all_matches.sort()
removals: list[tuple[int, int]] = []
for ms, me, g1s, g1e, _ in all_matches:
if g1s > ms:
removals.append((ms, g1s - ms))
if me > g1e:
removals.append((g1e, me - g1e))
removals.sort()
def _adjust(pos: int) -> int:
shift = 0
for remove_pos, remove_len in removals:
if remove_pos < pos:
shift += min(remove_len, pos - remove_pos)
else:
break
return pos - shift
adjusted_prior: list[tuple[int, int, str]] = []
for start, length, style in styles:
new_start = _adjust(start)
new_end = _adjust(start + length)
if new_end > new_start:
adjusted_prior.append((new_start, new_end - new_start, style))
result = ""
last_end = 0
inline_styles: list[tuple[int, int, str]] = []
for ms, me, g1s, g1e, style in all_matches:
result += text[last_end:ms]
pos = len(result)
inner = text[g1s:g1e]
result += inner
inline_styles.append((pos, len(inner), style))
last_end = me
result += text[last_end:]
text = result
styles = adjusted_prior + inline_styles
style_strings: list[str] = []
for cp_start, cp_len, style_type in sorted(styles):
if cp_start < 0 or cp_start + cp_len > len(text):
continue
u16_start = _utf16_len(text[:cp_start])
u16_len = _utf16_len(text[cp_start : cp_start + cp_len])
style_strings.append(f"{u16_start}:{u16_len}:{style_type}")
return text, style_strings
+374
View File
@@ -0,0 +1,374 @@
"""
Signal attachment rate-limit scheduler.
Process-wide token-bucket simulator that mirrors the per-account
attachment rate limit signal-cli/Signal-Server enforce. Producers
(``SignalAdapter.send_multiple_images`` and the ``send_message`` tool's
Signal path) call ``acquire(n)`` before an attachment send; on a 429
they call ``feedback(retry_after, n)`` so the model recalibrates from
the server's authoritative hint.
The scheduler serializes concurrent calls through an ``asyncio.Lock``,
giving FIFO fairness across agent sessions sharing one signal-cli
daemon.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
from typing import Any, Optional
from agent.retry_utils import parse_retry_after_seconds
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SIGNAL_MAX_ATTACHMENTS_PER_MSG = 32 # per-message attachment cap (source: Signal-{Android,Desktop} source code)
SIGNAL_RATE_LIMIT_BUCKET_CAPACITY = 50 # server-side token-bucket capacity for attachments rate limiting
SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER = 4 # fallback token refill interval for signal-cli < v0.14.3
SIGNAL_RATE_LIMIT_MAX_ATTEMPTS = 2 # initial attempt + 1 retry
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD = 10.0 # if estimated waiting time > 10s, notify the user about the delay
SIGNAL_RPC_ERROR_RATELIMIT = -5 # signal-cli (v0.14.3+) JSON-RPC error code for RateLimitException
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class SignalRateLimitError(Exception):
"""
Raised by ``SignalAdapter._rpc`` for rate-limit responses when the
caller has opted in via ``raise_on_rate_limit=True``.
Carries the server-supplied per-token Retry-After (in seconds) on
signal-cli ≥ v0.14.3
``retry_after`` is None when the version doesn't expose it.
"""
def __init__(self, message: str, retry_after: Optional[float] = None) -> None:
super().__init__(message)
self.retry_after = retry_after
class SignalSchedulerError(Exception):
pass
# ---------------------------------------------------------------------------
# Detection helpers — used to fish a 429 out of signal-cli's various error
# shapes (typed code, [429] substring, libsignal-net RetryLaterException
# leaked through AttachmentInvalidException).
# ---------------------------------------------------------------------------
# "Retry after 4 seconds" / "retry after 4 second" — libsignal-net's
# RetryLaterException string form, surfaced when 429s hit during
# attachment upload (signal-cli wraps these as AttachmentInvalidException
# rather than RateLimitException, so the typed path doesn't fire).
_RETRY_AFTER_RE = re.compile(r"Retry after (\d+(?:\.\d+)?)\s*second", re.IGNORECASE)
def _extract_retry_after_seconds(err: Any) -> Optional[float]:
"""Pull the per-token Retry-After window from a signal-cli rate-limit error.
Tries two sources, in order:
1. ``error.data.response.results[*].retryAfterSeconds`` — the
structured field signal-cli ≥ v0.14.3 surfaces for plain
RateLimitException.
2. ``"Retry after N seconds"`` parsed out of the message — covers
libsignal-net's RetryLaterException that gets wrapped as
AttachmentInvalidException during attachment upload, where the
structured field stays null.
Numeric parsing delegates to the shared
:func:`agent.retry_utils.parse_retry_after_seconds` core.
Returns None when neither source yields a value.
"""
msg = ""
if isinstance(err, dict):
data = err.get("data") or {}
response = data.get("response") or {}
results = response.get("results") or []
candidates = [
parse_retry_after_seconds(r.get("retryAfterSeconds")) for r in results
if isinstance(r, dict) and r.get("retryAfterSeconds")
]
candidates = [c for c in candidates if c is not None]
if candidates:
return max(candidates)
msg = str(err.get("message", ""))
else:
msg = str(err)
match = _RETRY_AFTER_RE.search(msg)
return parse_retry_after_seconds(match.group(1)) if match else None
def _is_signal_rate_limit_error(err: Any) -> bool:
"""True if a signal-cli RPC error reflects a rate-limit failure.
Matches three layers:
- typed ``RATELIMIT_ERROR`` code (signal-cli ≥ v0.14.3, plain
RateLimitException)
- legacy ``[429] / RateLimitException`` substrings
- libsignal-net's ``RetryLaterException`` / ``Retry after N seconds``
surfaced inside ``AttachmentInvalidException`` when the rate
limit is hit during attachment upload — signal-cli never re-tags
these as RateLimitException, so substring is the only signal.
"""
if isinstance(err, dict) and err.get("code") == SIGNAL_RPC_ERROR_RATELIMIT:
return True
message = (
str(err.get("message", ""))
if isinstance(err, dict)
else str(err)
)
msg_lower = message.lower()
return (
"[429]" in message
or "ratelimit" in msg_lower
or "retrylaterexception" in msg_lower
or "retry after" in msg_lower
)
# ---------------------------------------------------------------------------
# Misc helpers
# ---------------------------------------------------------------------------
def _format_wait(seconds: float) -> str:
"""Human-friendly wait label for user-facing pacing notices."""
s = max(0.0, seconds)
if s < 90:
return f"{int(round(s))}s"
return f"{max(1, int(round(s / 60)))} min"
def _signal_send_timeout(num_attachments: int) -> float:
"""HTTP timeout for a Signal ``send`` RPC.
signal-cli uploads attachments serially during the call, so the
server-side time scales with batch size. Default 30s is fine for
text-only sends but truncates large attachment batches mid-upload —
we then log a phantom failure even though signal-cli completes the
send a few seconds later. Scale at 5s/attachment with a 60s floor.
"""
if num_attachments <= 0:
return 30.0
return max(60.0, 5.0 * num_attachments)
# ---------------------------------------------------------------------------
# Scheduler
# ---------------------------------------------------------------------------
class SignalAttachmentScheduler:
"""Process-wide token-bucket simulator for Signal attachment sends.
The bucket holds up to ``capacity`` tokens (default 50, matching
Signal's server-side rate-limit bucket size). Each attachment consumes one
token. Tokens refill at ``refill_rate`` tokens/second, calibrated
from the per-token Retry-After hint we get from the server when a
429 fires. Until we've observed one, we use the documented default
(1 token / 4 seconds).
Concurrent ``acquire(n)`` calls serialize through an
``asyncio.Lock`` — natural FIFO across agent sessions hitting the
same daemon.
"""
def __init__(
self,
capacity: float = float(SIGNAL_RATE_LIMIT_BUCKET_CAPACITY),
default_retry_after: float = float(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER),
) -> None:
self.capacity = float(capacity)
self.tokens = float(capacity)
self.refill_rate = 1.0 / float(default_retry_after)
self.last_refill = time.monotonic()
self._lock = asyncio.Lock()
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _refill(self) -> None:
now = time.monotonic()
elapsed = now - self.last_refill
if elapsed > 0 and self.tokens < self.capacity:
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
self.last_refill = now
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def estimate_wait(self, n: int) -> float:
"""Best-effort estimate of the seconds until ``n`` tokens would
be available. Used to decide whether to emit a user-facing
pacing notice *before* committing to an ``acquire`` that may
block silently. Lock-free; small races vs. concurrent acquires
are benign for an informational notice.
"""
now = time.monotonic()
elapsed = now - self.last_refill
projected = self.tokens
if elapsed > 0 and projected < self.capacity:
projected = min(self.capacity, projected + elapsed * self.refill_rate)
deficit = n - projected
if deficit <= 0:
return 0.0
return deficit / self.refill_rate
async def acquire(self, n: int) -> float:
"""Block until at least ``n`` tokens are available, return the
seconds slept.
Does **not** deduct tokens — the bucket is a read-only model of
server-side capacity. Call ``report_rpc_duration()`` after the
RPC to synchronise the model with the server timeline.
Not perfect in case lots of coroutines try to acquire for big
uploads (``report_rpc_duration`` will take a long time to get hit)
but this is just a simulation. Signal server is ground truth and
will raise rate-limit exceptions triggering requeues.
The lock is released during ``asyncio.sleep`` so other callers
can interleave. A retry loop re-checks after each sleep in
case the deadline was pessimistic.
"""
if n <= 0:
return 0.0
if n > self.capacity:
raise SignalSchedulerError(
f"Signal scheduler was called requesting {n} tokens "
f"(max is {self.capacity})",
)
total_slept = 0.0
first_pass = True
while True:
async with self._lock:
self._refill()
if self.tokens >= n:
if not first_pass or total_slept > 0:
logger.debug(
"Signal scheduler: tokens sufficient for %d "
"(remaining=%.1f, total_slept=%.1fs)",
n, self.tokens, total_slept,
)
return total_slept
deficit = n - self.tokens
wait = deficit / self.refill_rate
if first_pass:
logger.info(
"Signal scheduler: pausing %.1fs for %d tokens "
"(available=%.1f, deficit=%.1f, refill=%.4f/s ≈ %.1fs/token)",
wait, n, self.tokens, deficit,
self.refill_rate, 1.0 / self.refill_rate,
)
first_pass = False
await asyncio.sleep(wait)
total_slept += wait
async def report_rpc_duration(self, rpc_duration: float, n_attachments: int) -> None:
"""Record an attachment-send RPC that just completed.
Deducts ``n_attachments`` tokens without crediting refill during
the upload window. Signal's server checks the bucket at RPC start
and does *not* refill during request processing — refill resumes
after the response. Crediting upload-time refill causes cumulative
drift that eventually triggers 429s.
Advances ``last_refill`` so the next ``acquire`` / ``_refill``
starts counting from this point.
"""
if n_attachments <= 0:
return
async with self._lock:
now = time.monotonic()
token_before = self.tokens
self.tokens = max(0.0, token_before - float(n_attachments))
self.last_refill = now
logger.log(
logging.INFO if rpc_duration > 10 and n_attachments > 5 else logging.DEBUG,
"Signal scheduler: RPC for %d att took %.1fs — "
"tokens %.1f → %.1f (deducted=%d, no upload refill credited, refill=%.4fs⁻¹)",
n_attachments, rpc_duration,
token_before, self.tokens,
n_attachments, self.refill_rate,
)
def feedback(self, retry_after: Optional[float], n_attempted: int) -> None:
"""Apply server feedback after a 429.
``retry_after`` is the per-*token* refill window the server
reports (None when signal-cli is older than v0.14.3 and didn't
surface it).
When present we calibrate ``refill_rate`` from it:
the server is authoritative.
"""
if retry_after and retry_after > 0:
new_rate = 1.0 / float(retry_after)
if new_rate != self.refill_rate:
logger.info(
"Signal scheduler: calibrating refill_rate to %.4f tokens/sec "
"(server retry_after=%.1fs per token)",
new_rate, retry_after,
)
self.refill_rate = new_rate
self.tokens = 0.0
self.last_refill = time.monotonic()
def state(self) -> dict:
"""Return current scheduler state for diagnostic logging (read-only).
Does not advance ``last_refill`` — safe to call from logging paths
without perturbing the bucket.
"""
now = time.monotonic()
elapsed = now - self.last_refill
projected = self.tokens
if elapsed > 0 and projected < self.capacity:
projected = min(self.capacity, projected + elapsed * self.refill_rate)
return {
"tokens": round(projected, 1),
"capacity": int(self.capacity),
"refill_rate": round(self.refill_rate, 4),
"refill_seconds_per_token": round(1.0 / self.refill_rate, 1) if self.refill_rate > 0 else float("inf"),
}
# ---------------------------------------------------------------------------
# Process-wide singleton
# ---------------------------------------------------------------------------
_scheduler: Optional[SignalAttachmentScheduler] = None
def get_scheduler() -> SignalAttachmentScheduler:
"""Return the process-wide scheduler, creating it on first access."""
global _scheduler
if _scheduler is None:
_scheduler = SignalAttachmentScheduler()
logger.info(
"Signal scheduler: created (capacity=%d tokens, refill=%.4f/s ≈ %.1fs/token)",
int(_scheduler.capacity),
_scheduler.refill_rate,
1.0 / _scheduler.refill_rate,
)
return _scheduler
def _reset_scheduler() -> None:
"""Drop the cached scheduler so the next ``get_scheduler`` call
builds a fresh one. Test-only — never call from production paths."""
global _scheduler
_scheduler = None
File diff suppressed because it is too large. Load diff
+302
View File
@@ -0,0 +1,302 @@
"""Route-local filters and script transforms for the webhook adapter."""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
DEFAULT_SCRIPT_TIMEOUT_SECONDS = 30
_MISSING = object()
def _stringify_filter_value(value: Any) -> str:
if value is _MISSING:
return ""
if isinstance(value, (dict, list)):
return json.dumps(value, sort_keys=True)
return str(value)
def _resolve_profile_path(path_value: Any) -> Optional[Path]:
"""Resolve a user path, mapping ~/.hermes to the active profile home."""
if not isinstance(path_value, str):
return None
raw = os.path.expandvars(path_value.strip())
if not raw:
return None
from hermes_constants import get_hermes_home
hermes_home = get_hermes_home()
if raw == "~/.hermes":
return hermes_home
if raw.startswith("~/.hermes/"):
return hermes_home / raw.removeprefix("~/.hermes/")
path = Path(raw).expanduser()
if path.is_absolute():
return path
return hermes_home / path
def _resolve_script_path(script_value: Any) -> tuple[Optional[Path], Optional[str]]:
"""Resolve a route script under HERMES_HOME/scripts."""
if not isinstance(script_value, str) or not script_value.strip():
return None, "script path is empty"
from hermes_constants import get_hermes_home
scripts_root = (get_hermes_home() / "scripts").resolve()
raw_text = os.path.expandvars(script_value.strip())
if raw_text == "~/.hermes" or raw_text.startswith("~/.hermes/"):
mapped = _resolve_profile_path(raw_text)
candidate = mapped.resolve() if mapped is not None else scripts_root
else:
raw = Path(raw_text).expanduser()
candidate = raw.resolve() if raw.is_absolute() else (scripts_root / raw).resolve()
try:
candidate.relative_to(scripts_root)
except ValueError:
return None, f"script path resolves outside {scripts_root}"
if not candidate.exists():
return None, f"script not found: {candidate}"
if not candidate.is_file():
return None, f"script path is not a file: {candidate}"
return candidate, None
def _load_filter_file_values(path_value: Any) -> list[Any]:
path = _resolve_profile_path(path_value)
if path is None:
return []
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning("[webhook] filter in_file read failed for %s: %s", path, exc)
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return [line.strip() for line in raw.splitlines() if line.strip()]
if isinstance(data, list):
return data
if isinstance(data, dict):
return list(data.keys())
return [data]
class WebhookRouteProcessor:
"""Evaluate declarative filters and optional script transforms."""
def __init__(
self,
*,
script_timeout_seconds: int = DEFAULT_SCRIPT_TIMEOUT_SECONDS,
) -> None:
self.script_timeout_seconds = max(1, int(script_timeout_seconds))
def resolve_filter_field(
self,
field: Any,
payload: dict,
event_type: str,
headers: Any,
) -> Any:
"""Resolve a dotted filter field against payload/event/headers context."""
if not isinstance(field, str) or not field.strip():
return _MISSING
parts = [part for part in field.strip().split(".") if part]
if not parts:
return _MISSING
header_dict = dict(headers or {})
context = {
"payload": payload.get("payload", payload),
"event": event_type,
"event_type": event_type,
"headers": header_dict,
}
if parts[0] in context:
value: Any = context[parts[0]]
parts = parts[1:]
else:
value = payload
for part in parts:
if isinstance(value, dict):
value = value.get(part, _MISSING)
elif isinstance(value, list) and part.isdigit():
idx = int(part)
value = value[idx] if 0 <= idx < len(value) else _MISSING
else:
return _MISSING
if value is _MISSING:
return _MISSING
return value
def filter_matches(
self,
spec: Any,
payload: dict,
event_type: str,
headers: Any,
) -> bool:
"""Evaluate one declarative webhook filter spec."""
if not isinstance(spec, dict):
logger.warning("[webhook] Ignoring invalid filter spec: %r", spec)
return False
if "all" in spec:
items = spec.get("all")
return isinstance(items, list) and all(
self.filter_matches(item, payload, event_type, headers)
for item in items
)
if "any" in spec:
items = spec.get("any")
return isinstance(items, list) and any(
self.filter_matches(item, payload, event_type, headers)
for item in items
)
if "not" in spec:
return not self.filter_matches(spec.get("not"), payload, event_type, headers)
value = self.resolve_filter_field(
spec.get("field"), payload, event_type, headers
)
if "exists" in spec:
exists = value is not _MISSING
return exists is bool(spec.get("exists"))
if spec.get("missing") is True:
return value is _MISSING
if "equals" in spec:
return value is not _MISSING and value == spec.get("equals")
if "not_equals" in spec:
return value is _MISSING or value != spec.get("not_equals")
if "contains" in spec:
needle = spec.get("contains")
if value is _MISSING:
return False
if isinstance(value, (list, tuple, set, dict)):
return needle in value
return str(needle) in _stringify_filter_value(value)
if "in" in spec:
haystack = spec.get("in")
return isinstance(haystack, list) and value in haystack
if "in_file" in spec:
return value in _load_filter_file_values(spec.get("in_file"))
if "regex" in spec:
if value is _MISSING:
return False
try:
return (
re.search(str(spec.get("regex")), _stringify_filter_value(value))
is not None
)
except re.error as exc:
logger.warning("[webhook] Invalid webhook filter regex: %s", exc)
return False
logger.warning("[webhook] Filter spec has no supported operator: %r", spec)
return False
def route_filters_match(
self,
route_config: dict,
payload: dict,
event_type: str,
headers: Any,
) -> bool:
filters = route_config.get("filters") or []
if not filters:
return True
if isinstance(filters, dict):
return self.filter_matches(filters, payload, event_type, headers)
if not isinstance(filters, list):
logger.warning("[webhook] filters must be a list or object")
return False
return all(
self.filter_matches(spec, payload, event_type, headers)
for spec in filters
)
def run_route_script(self, script_value: Any, payload: dict) -> tuple[bool, Optional[dict]]:
"""Run a route script and return (should_continue, transformed_payload)."""
path, error = _resolve_script_path(script_value)
if error or path is None:
logger.warning("[webhook] script ignored webhook: %s", error)
return False, None
suffix = path.suffix.lower()
if suffix in {".sh", ".bash"}:
bash = shutil.which("bash") or (
"/bin/bash" if os.path.isfile("/bin/bash") else None
)
if bash is None:
logger.warning("[webhook] script ignored webhook: bash not found")
return False, None
argv = [bash, str(path)]
else:
argv = [sys.executable, str(path)]
try:
from tools.environments.local import build_subprocess_env
popen_kwargs = {"creationflags": 0x08000000} if sys.platform == "win32" else {}
result = subprocess.run(
argv,
input=json.dumps(payload),
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=self.script_timeout_seconds,
cwd=str(path.parent),
env=build_subprocess_env(),
**popen_kwargs,
)
except subprocess.TimeoutExpired:
logger.warning("[webhook] script timed out: %s", path)
return False, None
except Exception as exc:
logger.warning("[webhook] script execution failed: %s", exc)
return False, None
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()
try:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
stderr = redact_sensitive_text(stderr)
except Exception as exc:
logger.warning("[webhook] Failed to redact script output: %s", exc)
stdout = "[REDACTED - redaction failed]"
stderr = "[REDACTED - redaction failed]"
if result.returncode != 0:
logger.info(
"[webhook] script ignored webhook path=%s code=%s stderr=%s",
path.name,
result.returncode,
stderr[:200],
)
return False, None
if not stdout or stdout == "[SILENT]":
return False, None
try:
transformed = json.loads(stdout)
except json.JSONDecodeError:
transformed = {**payload, "script_output": stdout}
if not isinstance(transformed, dict):
logger.warning("[webhook] script stdout must be a JSON object or text")
return False, None
if (
transformed.get("[SILENT]") is True
or transformed.get("__hermes_ignore__") is True
):
return False, None
return True, transformed
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+552
View File
@@ -0,0 +1,552 @@
"""
Transport-agnostic WhatsApp behavior shared by the Baileys bridge adapter
and the official WhatsApp Cloud API adapter.
The mixin provides:
- Allow-list / DM / group gating
- Mention detection (explicit @-mentions + configurable regex patterns)
- Quoted-reply-to-bot detection
- Broadcast / Channel / Newsletter filtering
- WhatsApp-flavored markdown conversion
- Outgoing chunk length budgeting
It is the *behavior layer*. Transport-specific concerns (subprocess management,
HTTP webhooks, Graph API calls, media upload protocols) live in each adapter.
Mixin contract — the adapter must set these on ``self`` before any of the
mixin's methods are called (typically in ``__init__``):
self.config # gateway.config.PlatformConfig
self.name # str — adapter name (used in log lines)
self._dm_policy # str: "open" | "allowlist" | "disabled"
self._allow_from # set[str]
self._group_policy # str: "open" | "allowlist" | "disabled"
self._group_allow_from # set[str]
self._mention_patterns # list[re.Pattern]
self._reply_prefix # Optional[str]
Class attributes ``MAX_MESSAGE_LENGTH`` and ``DEFAULT_REPLY_PREFIX`` are
defined on the mixin and may be overridden per-adapter if needed.
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Any, Dict, Optional
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
def _get_wsecret(name, default=None):
"""Scope-aware WHATSAPP_* read with the default-profile startup fallback.
Secondary profiles run under ``_profile_runtime_scope`` -- the scope is
authoritative and a scoped miss returns ``default`` (no cross-profile
borrow). The DEFAULT profile's adapter constructs and sends *unscoped*
under multiplexing, where a bare ``get_secret`` would raise
``UnscopedSecretError`` and crash its WhatsApp path; there ``os.environ``
is that profile's own value, so fall back to it. Same pattern as the
Slack ``SLACK_APP_TOKEN`` read (#59739).
"""
try:
val = _scoped_get_secret(name, default)
except _UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default
logger = logging.getLogger(__name__)
class WhatsAppBehaviorMixin:
"""Shared behavior for all WhatsApp adapters (Baileys + Cloud API).
See module docstring for the attribute contract the host adapter must
satisfy. This mixin owns no state of its own — every value it touches
is either a class attribute or set by the adapter's ``__init__``.
"""
# WhatsApp message limits — practical UX limit, not protocol max.
# WhatsApp allows ~65K but long messages are unreadable on mobile.
MAX_MESSAGE_LENGTH: int = 4096
supports_code_blocks = True # WhatsApp renders fenced code blocks (monospace)
DEFAULT_REPLY_PREFIX: str = "⚕ *Hermes Agent*\n────────────\n"
_OUTBOUND_INVISIBLE_CHARS_RE = re.compile(r"[\u200b\u2060\u2063\ufeff]")
_OUTBOUND_ODD_SPACE_RE = re.compile(r"[\u00a0\u1680\u180e\u2000-\u200a\u202f\u205f\u3000]")
@classmethod
def _sanitize_outbound_text(cls, content: str) -> str:
"""Remove invisible formatting chars that leak badly in WhatsApp.
Some provider/gateway formatting paths can emit unicode like WORD
JOINER (U+2060) plus NARROW NO-BREAK SPACE (U+202F). WhatsApp may
render those as mojibake-looking prefixes (``⁠ text``) instead of
invisible spacing. Keep normal text and emoji joiners intact, but
strip known zero-width format chars and normalize odd unicode spaces.
"""
if not content:
return content
content = cls._OUTBOUND_INVISIBLE_CHARS_RE.sub("", content)
return cls._OUTBOUND_ODD_SPACE_RE.sub(" ", content)
@property
def enforces_own_access_policy(self) -> bool:
"""WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
return True
# ------------------------------------------------------------------ config
def _effective_reply_prefix(self) -> str:
"""Return the prefix to add to outgoing replies in self-chat mode.
Subclasses that don't have a self-chat concept (the Cloud API
adapter) can override this to always return ``""`` or apply a
different policy.
"""
whatsapp_mode = _get_wsecret("WHATSAPP_MODE", default="self-chat") or "self-chat"
if whatsapp_mode != "self-chat":
return ""
if self._reply_prefix is not None:
return self._reply_prefix.replace("\\n", "\n")
env_prefix = _get_wsecret("WHATSAPP_REPLY_PREFIX")
if env_prefix is not None:
return env_prefix.replace("\\n", "\n")
return self.DEFAULT_REPLY_PREFIX
def _outgoing_chunk_limit(self) -> int:
"""Reserve room for the reply prefix so the final message fits."""
prefix_len = len(self._effective_reply_prefix())
# Keep enough space for truncate_message's pagination indicator and
# code-fence repair even if a user configures a very long prefix.
return max(1024, self.MAX_MESSAGE_LENGTH - prefix_len)
def _whatsapp_require_mention(self) -> bool:
configured = self.config.extra.get("require_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return (_get_wsecret("WHATSAPP_REQUIRE_MENTION", default="false") or "false").lower() in {
"true",
"1",
"yes",
"on",
}
def _whatsapp_free_response_chats(self) -> set[str]:
raw = self.config.extra.get("free_response_chats")
if raw is None:
raw = _get_wsecret("WHATSAPP_FREE_RESPONSE_CHATS", default="") or ""
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
@staticmethod
def _coerce_allow_list(raw) -> set[str]:
"""Parse allow_from / group_allow_from from config or env var."""
if raw is None:
return set()
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _live_dm_allow_from(self) -> set[str]:
"""Allowlist currently enforced for DM intake / strict DM auth.
Source precedence matches construction: explicit config wins over any
env carrier. When the adapter was seeded from an env var, re-read that
same key so pairing approve/revoke takes effect without restart
(including an empty value while the key is still present). When the key
is absent — sole-entry revoke calls ``remove_env_value`` — treat the
allowlist as empty instead of falling back to the construction-time
snapshot. Config-seeded adapters keep the in-memory snapshot, which
pairing revoke purges in place — a lower-precedence or stale env value
must not broaden access.
"""
source = getattr(self, "_dm_allowlist_source", None)
if isinstance(source, str) and source != "config":
if source in os.environ:
return self._coerce_allow_list(os.environ.get(source, ""))
# Key removed (e.g. sole-entry pairing revoke) — do not revive the
# stale construction snapshot.
return set()
return set(self._allow_from or ())
# ------------------------------------------------------------------ JID helpers
@staticmethod
def _normalize_whatsapp_id(value: Optional[str]) -> str:
if not value:
return ""
normalized = str(value).strip()
if ":" in normalized and "@" in normalized:
normalized = normalized.replace(":", "@", 1)
return normalized
@staticmethod
def _is_broadcast_chat(chat_id: str) -> bool:
"""True for WhatsApp pseudo-chats that aren't real conversations.
Covers Status updates (Stories) and Channel/Newsletter broadcasts.
These show up as inbound messages on Baileys but the agent should
never reply — answering a Story update spams the contact's status
feed, and Channel posts aren't addressable in the first place.
"""
if not chat_id:
return False
cid = chat_id.strip().lower()
if cid == "status@broadcast":
return True
# @broadcast suffix covers status@broadcast plus any future
# broadcast-list variants. @newsletter is the Channel JID suffix.
if cid.endswith("@broadcast") or cid.endswith("@newsletter"):
return True
return False
# ------------------------------------------------------------------ gating
def _open_dm_opted_in(self) -> bool:
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}:
return True
return (_get_wsecret("WHATSAPP_ALLOW_ALL_USERS", default="") or "").lower() in {"true", "1", "yes"}
@staticmethod
def _matches_whatsapp_allowlist(candidate: str, allow_from) -> bool:
"""Match a WhatsApp identifier against an allowlist across phone/LID forms.
WhatsApp delivers inbound senders in LID form (``<id>@lid``) while
operators usually configure allowlists with phone numbers, and vice
versa. A raw set-membership check therefore never matches a known
contact. Resolve both the candidate and each allowlist entry through
the bridge's ``lid-mapping-*.json`` files (the shared
``gateway.whatsapp_identity`` helper that the gateway authz and
session-key paths already use) so either configured form resolves to
the inbound form.
"""
if not allow_from:
return False
# Fast path: exact match against the raw configured value (e.g. a full
# ``@g.us`` group JID or an entry that already matches verbatim).
if candidate in allow_from:
return True
from gateway.whatsapp_identity import (
expand_whatsapp_aliases,
normalize_whatsapp_identifier,
)
candidate_aliases = expand_whatsapp_aliases(candidate)
if not candidate_aliases:
return False
for entry in allow_from:
if entry == "*":
return True
if normalize_whatsapp_identifier(entry) in candidate_aliases:
return True
# Entry may itself be an unmapped form; expand it too so a phone
# allowlist entry resolves when the inbound sender arrived as a LID.
if expand_whatsapp_aliases(entry) & candidate_aliases:
return True
return False
def _is_dm_allowed(self, sender_id: str) -> bool:
"""Strict DM authorization — pairing does not imply access."""
if self._dm_policy == "disabled":
return False
if self._dm_policy == "allowlist":
return self._matches_whatsapp_allowlist(sender_id, self._live_dm_allow_from())
if self._dm_policy == "open":
return self._open_dm_opted_in()
return False
def _is_dm_intake_allowed(self, sender_id: str) -> bool:
"""Whether a DM may reach the gateway intake (pairing handshake path)."""
principal = str(sender_id or "").strip()
if not principal:
return False
if self._dm_policy == "disabled":
return False
if self._dm_policy == "allowlist":
return self._matches_whatsapp_allowlist(principal, self._live_dm_allow_from())
if self._dm_policy == "pairing":
return True
if self._dm_policy == "open":
return self._open_dm_opted_in()
return False
def _is_group_allowed(self, chat_id: str) -> bool:
"""Check whether a group chat should be processed."""
if self._group_policy == "disabled":
return False
if self._group_policy == "allowlist":
return self._matches_whatsapp_allowlist(chat_id, self._group_allow_from)
if self._group_policy == "pairing":
return False
if self._group_policy == "open":
return True
return False
def _compile_mention_patterns(self):
patterns = self.config.extra.get("mention_patterns")
if patterns is None:
raw = (_get_wsecret("WHATSAPP_MENTION_PATTERNS", default="") or "").strip()
if raw:
try:
patterns = json.loads(raw)
except Exception:
patterns = [
part.strip() for part in raw.splitlines() if part.strip()
]
if not patterns:
patterns = [
part.strip() for part in raw.split(",") if part.strip()
]
if patterns is None:
return []
if isinstance(patterns, str):
patterns = [patterns]
if not isinstance(patterns, list):
logger.warning(
"[%s] whatsapp mention_patterns must be a list or string; got %s",
self.name,
type(patterns).__name__,
)
return []
compiled = []
for pattern in patterns:
if not isinstance(pattern, str) or not pattern.strip():
continue
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
logger.warning(
"[%s] Invalid WhatsApp mention pattern %r: %s",
self.name,
pattern,
exc,
)
if compiled:
logger.info(
"[%s] Loaded %d WhatsApp mention pattern(s)", self.name, len(compiled)
)
return compiled
def _bot_ids_from_message(self, data: Dict[str, Any]) -> set[str]:
bot_ids = set()
for candidate in data.get("botIds") or []:
normalized = self._normalize_whatsapp_id(candidate)
if normalized:
bot_ids.add(normalized)
return bot_ids
def _message_is_reply_to_bot(self, data: Dict[str, Any]) -> bool:
quoted_participant = self._normalize_whatsapp_id(data.get("quotedParticipant"))
if not quoted_participant:
return False
return quoted_participant in self._bot_ids_from_message(data)
def _message_mentions_bot(self, data: Dict[str, Any]) -> bool:
bot_ids = self._bot_ids_from_message(data)
if not bot_ids:
return False
mentioned_ids = {
nid
for candidate in (data.get("mentionedIds") or [])
if (nid := self._normalize_whatsapp_id(candidate))
}
if mentioned_ids & bot_ids:
return True
body = str(data.get("body") or "")
lower_body = body.lower()
for bot_id in bot_ids:
bare_id = bot_id.split("@", 1)[0].lower()
if bare_id and (f"@{bare_id}" in lower_body or bare_id in lower_body):
return True
return False
def _message_matches_mention_patterns(self, data: Dict[str, Any]) -> bool:
if not self._mention_patterns:
return False
body = str(data.get("body") or "")
return any(pattern.search(body) for pattern in self._mention_patterns)
def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str:
if not text:
return text
bot_ids = self._bot_ids_from_message(data)
cleaned = text
for bot_id in bot_ids:
bare_id = bot_id.split("@", 1)[0]
if bare_id:
cleaned = re.sub(
rf"@{re.escape(bare_id)}\b[,:\-]*\s*", "", cleaned
)
return cleaned.strip() or text
def _should_process_message(self, data: Dict[str, Any]) -> bool:
chat_id_raw = str(data.get("chatId") or "")
# WhatsApp uses pseudo-chats for Status updates (Stories) and
# Channel/Newsletter broadcasts. These are not real conversations
# and the agent should never reply to them — even in self-chat mode
# where the bridge may surface them as "fromMe" events.
if self._is_broadcast_chat(chat_id_raw):
return False
is_group = data.get("isGroup", False)
if is_group:
chat_id = chat_id_raw
if not self._is_group_allowed(chat_id):
return False
else:
sender_id = str(data.get("senderId") or data.get("from") or "")
if not self._is_dm_intake_allowed(sender_id):
return False
# DMs that pass the policy gate are always processed
return True
# Group messages: check mention / free-response settings
chat_id = str(data.get("chatId") or "")
if chat_id in self._whatsapp_free_response_chats():
return True
if not self._whatsapp_require_mention():
return True
body = str(data.get("body") or "").strip()
if body.startswith("/"):
return True
if self._message_is_reply_to_bot(data):
return True
if self._message_mentions_bot(data):
return True
return self._message_matches_mention_patterns(data)
# ------------------------------------------------------------------ formatting
def format_message(self, content: str) -> str:
"""Convert standard markdown to WhatsApp-compatible formatting.
WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```,
and monospaced `inline`. Standard markdown uses different syntax
for bold/italic/strikethrough, so we convert here.
Code blocks (``` fenced) and inline code (`) are protected from
conversion via placeholder substitution.
"""
if not content:
return content
content = self._sanitize_outbound_text(content)
# --- 1. Protect fenced code blocks from formatting changes ---
_FENCE_PH = "\x00FENCE"
fences: list[str] = []
def _save_fence(m: re.Match) -> str:
fences.append(m.group(0))
return f"{_FENCE_PH}{len(fences) - 1}\x00"
result = re.sub(r"```[\s\S]*?```", _save_fence, content)
# --- 2. Protect inline code ---
_CODE_PH = "\x00CODE"
codes: list[str] = []
def _save_code(m: re.Match) -> str:
codes.append(m.group(0))
return f"{_CODE_PH}{len(codes) - 1}\x00"
result = re.sub(r"`[^`\n]+`", _save_code, result)
# --- 3. Convert markdown formatting to WhatsApp syntax ---
# Italic: standard Markdown *text* → WhatsApp _text_. Do this before
# bold conversion so **bold** does not become italic by accident. The
# lookarounds avoid list bullets and bold delimiters.
result = re.sub(
r"(?<!\*)\*(?!\s|\*)([^*\n]*?\S[^*\n]*?)\*(?!\*)",
r"_\1_",
result,
)
# Bold: **text** or __text__ → *text*
result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)
result = re.sub(r"__(.+?)__", r"*\1*", result)
# Strikethrough: ~~text~~ → ~text~
result = re.sub(r"~~(.+?)~~", r"~\1~", result)
# _text_ is already WhatsApp italic — leave as-is
# --- 4. Convert markdown headers to bold text ---
# # Header → *Header*. Strip any *...* wrapping already produced
# by step 3 (e.g. "# **Title**" → "*Title*", not "**Title**",
# which WhatsApp renders with literal asterisks).
def _header_to_bold(m: re.Match) -> str:
inner = m.group(1).strip()
while len(inner) > 1 and inner.startswith("*") and inner.endswith("*"):
inner = inner[1:-1].strip()
return f"*{inner}*"
result = re.sub(
r"^#{1,6}\s+(.+)$", _header_to_bold, result, flags=re.MULTILINE
)
# --- 5. Convert markdown links: [text](url) → text (url) ---
result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result)
# --- 6. Restore protected sections ---
for i, fence in enumerate(fences):
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
# ---------------------------------------------------------------------------
# Shared bridge directory resolution for CLI and adapter
# ---------------------------------------------------------------------------
def resolve_whatsapp_bridge_dir() -> Path:
"""Resolve the WhatsApp bridge directory, mirroring to HERMES_HOME if needed.
When the install tree is read-only (e.g., Docker /opt/hermes), this function
mirrors the bridge source to a writable HERMES_HOME location and returns that
path. This ensures npm install works in Docker environments.
Returns the resolved bridge directory path.
"""
import shutil
from pathlib import Path as _Path
# Default location in install tree (may be read-only)
from hermes_constants import get_hermes_home
install_bridge = _Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
# Try HERMES_HOME location first
hermes_home = get_hermes_home()
hermes_home_bridge = hermes_home / "scripts" / "whatsapp-bridge"
# Check if install dir is writable
try:
test_file = install_bridge / ".write_test"
test_file.touch()
test_file.unlink()
install_writable = True
except (OSError, PermissionError):
install_writable = False
if install_writable:
return install_bridge
# Install dir is read-only, mirror to HERMES_HOME if needed
if hermes_home_bridge.exists():
return hermes_home_bridge
# Mirror the bridge source to HERMES_HOME
try:
hermes_home_bridge.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(
install_bridge,
hermes_home_bridge,
dirs_exist_ok=False,
)
return hermes_home_bridge
except Exception:
return install_bridge
File diff suppressed because it is too large. Load diff
+665
View File
@@ -0,0 +1,665 @@
"""
yuanbao_media.py — 元宝平台媒体处理模块
提供 COS 上传、文件下载、TIM 媒体消息构建等功能。
移植自 TypeScript 版 media.ts(yuanbao-openclaw-plugin),
使用 httpx 替代 cos-nodejs-sdk-v5,避免引入额外 SDK 依赖。
COS 上传流程:
1. 调用 genUploadInfo 获取临时凭证(tmpSecretId/tmpSecretKey/sessionToken)
2. 用临时凭证通过 HMAC-SHA1 签名构建 Authorization 头
3. HTTP PUT 上传到 COS
TIM 消息体构建:
- buildImageMsgBody() → TIMImageElem
- buildFileMsgBody() → TIMFileElem
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import struct
import time
import urllib.parse
from typing import Optional, Any
import httpx
logger = logging.getLogger(__name__)
# ============ 常量 ============
UPLOAD_INFO_PATH = "/api/resource/genUploadInfo"
DEFAULT_API_DOMAIN = "yuanbao.tencent.com"
DEFAULT_MAX_SIZE_MB = 50
# COS 加速域名后缀(优先使用全球加速)
COS_USE_ACCELERATE = True
# ============ 类型映射 ============
# MIME → image_format 数字(TIM 协议字段)
_MIME_TO_IMAGE_FORMAT: dict[str, int] = {
"image/jpeg": 1,
"image/jpg": 1,
"image/gif": 2,
"image/png": 3,
"image/bmp": 4,
"image/webp": 255,
"image/heic": 255,
"image/tiff": 255,
}
# 文件扩展名 → MIME
_EXT_TO_MIME: dict[str, str] = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".heic": "image/heic",
".tiff": "image/tiff",
".ico": "image/x-icon",
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".txt": "text/plain",
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".wav": "audio/wav",
".ogg": "audio/ogg",
".webm": "video/webm",
}
# ============ 工具函数 ============
def guess_mime_type(filename: str) -> str:
"""根据文件扩展名猜测 MIME 类型。"""
ext = os.path.splitext(filename)[-1].lower()
return _EXT_TO_MIME.get(ext, "application/octet-stream")
def is_image(filename: str, mime_type: str = "") -> bool:
"""判断是否为图片类型。"""
if mime_type.startswith("image/"):
return True
ext = os.path.splitext(filename)[-1].lower()
return ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".tiff", ".ico"}
def get_image_format(mime_type: str) -> int:
"""获取 TIM 图片格式编号。"""
return _MIME_TO_IMAGE_FORMAT.get(mime_type.lower(), 255)
def md5_hex(data: bytes) -> str:
"""计算 MD5 十六进制摘要。"""
return hashlib.md5(data).hexdigest()
def generate_file_id() -> str:
"""生成随机文件 ID(32 位 hex)。"""
return secrets.token_hex(16)
# ============ 图片尺寸解析(纯 Python,无需 Pillow) ============
def parse_image_size(data: bytes) -> Optional[dict[str, int]]:
"""
解析图片宽高(支持 JPEG/PNG/GIF/WebP),无需第三方依赖。
返回 {"width": w, "height": h} 或 None(无法识别)。
"""
return (
_parse_png_size(data)
or _parse_jpeg_size(data)
or _parse_gif_size(data)
or _parse_webp_size(data)
)
def _parse_png_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 24:
return None
if buf[:4] != b"\x89PNG":
return None
w = struct.unpack(">I", buf[16:20])[0]
h = struct.unpack(">I", buf[20:24])[0]
return {"width": w, "height": h}
def _parse_jpeg_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 4 or buf[0] != 0xFF or buf[1] != 0xD8:
return None
i = 2
while i < len(buf) - 9:
if buf[i] != 0xFF:
i += 1
continue
marker = buf[i + 1]
if marker in {0xC0, 0xC2}:
h = struct.unpack(">H", buf[i + 5: i + 7])[0]
w = struct.unpack(">H", buf[i + 7: i + 9])[0]
return {"width": w, "height": h}
if i + 3 < len(buf):
i += 2 + struct.unpack(">H", buf[i + 2: i + 4])[0]
else:
break
return None
def _parse_gif_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 10:
return None
sig = buf[:6].decode("ascii", errors="replace")
if sig not in {"GIF87a", "GIF89a"}:
return None
w = struct.unpack("<H", buf[6:8])[0]
h = struct.unpack("<H", buf[8:10])[0]
return {"width": w, "height": h}
def _parse_webp_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 16:
return None
if buf[:4] != b"RIFF" or buf[8:12] != b"WEBP":
return None
chunk = buf[12:16].decode("ascii", errors="replace")
if chunk == "VP8 ":
if len(buf) >= 30 and buf[23] == 0x9D and buf[24] == 0x01 and buf[25] == 0x2A:
w = struct.unpack("<H", buf[26:28])[0] & 0x3FFF
h = struct.unpack("<H", buf[28:30])[0] & 0x3FFF
return {"width": w, "height": h}
elif chunk == "VP8L":
if len(buf) >= 25 and buf[20] == 0x2F:
bits = struct.unpack("<I", buf[21:25])[0]
w = (bits & 0x3FFF) + 1
h = ((bits >> 14) & 0x3FFF) + 1
return {"width": w, "height": h}
elif chunk == "VP8X":
if len(buf) >= 30:
w = (buf[24] | (buf[25] << 8) | (buf[26] << 16)) + 1
h = (buf[27] | (buf[28] << 8) | (buf[29] << 16)) + 1
return {"width": w, "height": h}
return None
# ============ URL 下载 ============
async def download_url(
url: str,
max_size_mb: int = DEFAULT_MAX_SIZE_MB,
) -> tuple[bytes, str]:
"""
下载 URL 内容,返回 (bytes, content_type)。
Args:
url: HTTP(S) URL
max_size_mb: 最大允许大小(MB),超过则抛出异常
Returns:
(data_bytes, content_type_string)
Raises:
ValueError: 内容超过大小限制
httpx.HTTPError: 网络/HTTP 错误
"""
# SSRF protection: yuanbao downloads model-supplied and inbound URLs
# server-side. Reject private/internal targets up front, and re-validate
# every redirect hop so a public URL can't 302 to http://169.254.169.254/.
from tools.url_safety import create_ssrf_safe_async_client, is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {url}")
async def _redirect_guard(response: httpx.Response) -> None:
if response.is_redirect and response.next_request:
redirect_url = str(response.next_request.url)
if not is_safe_url(redirect_url):
raise ValueError(
f"Blocked redirect to private/internal address: {redirect_url}"
)
max_bytes = max_size_mb * 1024 * 1024
async with create_ssrf_safe_async_client(
timeout=30.0,
follow_redirects=True,
event_hooks={"response": [_redirect_guard]},
) as client:
# 先 HEAD 检查大小
try:
head = await client.head(url)
content_length = int(head.headers.get("content-length", 0) or 0)
if content_length > 0 and content_length > max_bytes:
raise ValueError(
f"文件过大: {content_length / 1024 / 1024:.1f} MB > {max_size_mb} MB"
)
except httpx.HTTPStatusError:
pass # 部分服务器不支持 HEAD,忽略
# GET 下载(流式读取,防止超限)
async with client.stream("GET", url) as resp:
resp.raise_for_status()
content_type = resp.headers.get("content-type", "").split(";")[0].strip()
chunks: list[bytes] = []
downloaded = 0
async for chunk in resp.aiter_bytes(65536):
downloaded += len(chunk)
if downloaded > max_bytes:
raise ValueError(
f"文件过大: 已超过 {max_size_mb} MB 限制"
)
chunks.append(chunk)
data = b"".join(chunks)
return data, content_type
# ============ COS 鉴权(HMAC-SHA1) ============
def _cos_sign(
method: str,
path: str,
params: dict[str, str],
headers: dict[str, str],
secret_id: str,
secret_key: str,
start_time: Optional[int] = None,
expire_seconds: int = 3600,
) -> str:
"""
构建 COS 请求签名(q-sign-algorithm=sha1 方案)。
参考:https://cloud.tencent.com/document/product/436/7778
Args:
method: HTTP 方法(小写,如 "put")
path: URL 路径(URL encode 后的小写)
params: URL 查询参数 dict(用于签名)
headers: 参与签名的请求头 dict(key 需小写)
secret_id: 临时 SecretId(tmpSecretId)
secret_key: 临时 SecretKey(tmpSecretKey)
start_time: 签名起始 Unix 时间戳(默认 now)
expire_seconds: 签名有效期(秒,默认 3600)
Returns:
Authorization header 值(完整字符串)
"""
now = int(time.time())
q_sign_time = f"{start_time or now};{(start_time or now) + expire_seconds}"
# Step 1: SignKey = HMAC-SHA1(SecretKey, q-sign-time)
sign_key = hmac.new(
secret_key.encode("utf-8"),
q_sign_time.encode("utf-8"),
hashlib.sha1,
).hexdigest()
# Step 2: HttpString
# 参数和头部需按字典序排列,key 小写
sorted_params = sorted((k.lower(), urllib.parse.quote(str(v), safe="") ) for k, v in params.items())
sorted_headers = sorted((k.lower(), urllib.parse.quote(str(v), safe="") ) for k, v in headers.items())
url_param_list = ";".join(k for k, _ in sorted_params)
url_params = "&".join(f"{k}={v}" for k, v in sorted_params)
header_list = ";".join(k for k, _ in sorted_headers)
header_str = "&".join(f"{k}={v}" for k, v in sorted_headers)
http_string = "\n".join([
method.lower(),
path,
url_params,
header_str,
"",
])
# Step 3: StringToSign = sha1 hash of HttpString
sha1_of_http = hashlib.sha1(http_string.encode("utf-8")).hexdigest()
string_to_sign = "\n".join([
"sha1",
q_sign_time,
sha1_of_http,
"",
])
# Step 4: Signature = HMAC-SHA1(SignKey, StringToSign)
signature = hmac.new(
sign_key.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).hexdigest()
return (
f"q-sign-algorithm=sha1"
f"&q-ak={secret_id}"
f"&q-sign-time={q_sign_time}"
f"&q-key-time={q_sign_time}"
f"&q-header-list={header_list}"
f"&q-url-param-list={url_param_list}"
f"&q-signature={signature}"
)
# ============ 主要公开 API ============
async def get_cos_credentials(
app_key: str,
api_domain: str,
token: str,
filename: str = "file",
file_id: Optional[str] = None,
bot_id: str = "",
route_env: str = "",
) -> dict:
"""
调用 genUploadInfo 接口获取 COS 临时密钥及上传配置。
Args:
app_key: 应用 Key(用于 X-ID 头)
api_domain: API 域名(如 https://bot.yuanbao.tencent.com)
token: 当前有效的签票 token(X-Token 头)
filename: 待上传的文件名(含扩展名)
file_id: 客户端生成的唯一文件 ID(不传则自动生成)
bot_id: Bot 账号 ID(用于 X-ID 头)
Returns:
COS 上传配置 dict,包含以下字段:
bucketName (str) — COS Bucket 名称
region (str) — COS 地域
location (str) — 上传 Key(对象路径)
encryptTmpSecretId (str) — 临时 SecretId
encryptTmpSecretKey(str) — 临时 SecretKey
encryptToken (str) — SessionToken
startTime (int) — 凭证起始时间戳(Unix)
expiredTime (int) — 凭证过期时间戳(Unix)
resourceUrl (str) — 上传后的公网访问 URL
resourceID (str) — 资源 ID(可选)
Raises:
RuntimeError: 接口返回非 0 code 或字段缺失
"""
if file_id is None:
file_id = generate_file_id()
upload_url = f"{api_domain.rstrip('/')}{UPLOAD_INFO_PATH}"
headers = {
"Content-Type": "application/json",
"X-Token": token,
"X-ID": bot_id or app_key,
"X-Source": "web",
}
if route_env:
headers["X-Route-Env"] = route_env
body = {
"fileName": filename,
"fileId": file_id,
"docFrom": "localDoc",
"docOpenId": "",
}
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(upload_url, json=body, headers=headers)
resp.raise_for_status()
result: dict[str, Any] = resp.json()
code = result.get("code")
if code != 0 and code is not None:
raise RuntimeError(
f"genUploadInfo 失败: code={code}, msg={result.get('msg', '')}"
)
data = result.get("data") or result
required_fields = ["bucketName", "location"]
missing = [f for f in required_fields if not data.get(f)]
if missing:
raise RuntimeError(
f"genUploadInfo 返回字段不完整: 缺少字段 {missing}"
)
return data
async def upload_to_cos(
file_bytes: bytes,
filename: str,
content_type: str,
credentials: dict,
bucket: str,
region: str,
) -> dict:
"""
通过 httpx PUT 请求将文件上传到 COS。
使用临时凭证(tmpSecretId/tmpSecretKey/sessionToken)构建 HMAC-SHA1 签名。
Args:
file_bytes: 文件二进制内容
filename: 文件名(用于辅助计算 MIME、UUID)
content_type: MIME 类型(如 "image/jpeg")
credentials: get_cos_credentials() 返回的 dict,包含:
encryptTmpSecretId → tmpSecretId
encryptTmpSecretKey → tmpSecretKey
encryptToken → sessionToken
location → COS key(对象路径)
resourceUrl → 上传后公网 URL
startTime → 凭证起始时间(Unix)
expiredTime → 凭证过期时间(Unix)
bucket: COS Bucket 名称(如 chatbot-1234567890)
region: COS 地域(如 ap-guangzhou)
Returns:
上传结果 dict,包含:
url (str) — COS 公网访问 URL
uuid (str) — 文件内容 MD5
size (int) — 文件大小(字节)
width (int, optional) — 图片宽度(仅图片)
height (int, optional) — 图片高度(仅图片)
Raises:
httpx.HTTPStatusError: COS 返回非 2xx 状态
RuntimeError: credentials 字段缺失
"""
secret_id: str = credentials.get("encryptTmpSecretId", "")
secret_key: str = credentials.get("encryptTmpSecretKey", "")
session_token: str = credentials.get("encryptToken", "")
cos_key: str = credentials.get("location", "")
resource_url: str = credentials.get("resourceUrl", "")
start_time: Optional[int] = credentials.get("startTime")
expired_time: Optional[int] = credentials.get("expiredTime")
if not secret_id or not secret_key or not cos_key:
raise RuntimeError(
f"COS credentials 不完整: secretId={bool(secret_id)}, "
f"secretKey={bool(secret_key)}, location={bool(cos_key)}"
)
# 构建 COS 上传 URL(优先使用全球加速域名)
if COS_USE_ACCELERATE:
cos_host = f"{bucket}.cos.accelerate.myqcloud.com"
else:
cos_host = f"{bucket}.cos.{region}.myqcloud.com"
# URL encode cos_key(保留 /)
encoded_key = urllib.parse.quote(cos_key, safe="/")
cos_url = f"https://{cos_host}/{encoded_key.lstrip('/')}"
# 确定 Content-Type
if not content_type or content_type == "application/octet-stream":
if is_image(filename):
content_type = guess_mime_type(filename)
else:
content_type = "application/octet-stream"
# 计算文件 MD5 + size
file_uuid = md5_hex(file_bytes)
file_size = len(file_bytes)
# 参与签名的请求头
sign_headers = {
"host": cos_host,
"content-type": content_type,
"x-cos-security-token": session_token,
}
# 计算签名有效期
now = int(time.time())
sign_start = start_time if start_time else now
sign_expire = (expired_time - now) if expired_time and expired_time > now else 3600
authorization = _cos_sign(
method="put",
path=f"/{encoded_key.lstrip('/')}",
params={},
headers=sign_headers,
secret_id=secret_id,
secret_key=secret_key,
start_time=sign_start,
expire_seconds=sign_expire,
)
put_headers = {
"Authorization": authorization,
"Content-Type": content_type,
"x-cos-security-token": session_token,
}
logger.info(
"COS PUT: bucket=%s region=%s key=%s size=%d mime=%s",
bucket, region, cos_key, file_size, content_type,
)
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.put(
cos_url,
content=file_bytes,
headers=put_headers,
)
resp.raise_for_status()
# 解析图片尺寸(仅图片类型)
result: dict[str, Any] = {
"url": resource_url or cos_url,
"uuid": file_uuid,
"size": file_size,
}
if content_type.startswith("image/"):
size_info = parse_image_size(file_bytes)
if size_info:
result["width"] = size_info["width"]
result["height"] = size_info["height"]
logger.info(
"COS 上传成功: url=%s size=%d",
result["url"], file_size,
)
return result
# ============ TIM 媒体消息构建 ============
def build_image_msg_body(
url: str,
uuid: Optional[str] = None,
filename: Optional[str] = None,
size: int = 0,
width: int = 0,
height: int = 0,
mime_type: str = "",
) -> list[dict]:
"""
构建腾讯 IM TIMImageElem 消息体。
参考:https://cloud.tencent.com/document/product/269/2720
Args:
url: 图片公网访问 URL(COS resourceUrl)
uuid: 文件 UUID(MD5 或其他唯一标识)
filename: 文件名(uuid 为空时作为备用)
size: 文件大小(字节)
width: 图片宽度(像素)
height: 图片高度(像素)
mime_type: MIME 类型(用于确定 image_format)
Returns:
TIMImageElem 消息体列表(适合直接放入 msg_body)
"""
_uuid = uuid or filename or _basename_from_url(url) or "image"
image_format = get_image_format(mime_type) if mime_type else 255
return [
{
"msg_type": "TIMImageElem",
"msg_content": {
"uuid": _uuid,
"image_format": image_format,
"image_info_array": [
{
"type": 1, # 1 = 原图
"size": size,
"width": width,
"height": height,
"url": url,
}
],
},
}
]
def build_file_msg_body(
url: str,
filename: str,
uuid: Optional[str] = None,
size: int = 0,
) -> list[dict]:
"""
构建腾讯 IM TIMFileElem 消息体。
参考:https://cloud.tencent.com/document/product/269/2720
Args:
url: 文件公网访问 URL(COS resourceUrl)
filename: 文件名(含扩展名)
uuid: 文件 UUID(MD5 或其他唯一标识,不传则使用 filename)
size: 文件大小(字节)
Returns:
TIMFileElem 消息体列表(适合直接放入 msg_body)
"""
_uuid = uuid or filename
return [
{
"msg_type": "TIMFileElem",
"msg_content": {
"uuid": _uuid,
"file_name": filename,
"file_size": size,
"url": url,
},
}
]
# ============ 内部工具 ============
def _basename_from_url(url: str) -> str:
"""从 URL 提取文件名。"""
try:
parsed = urllib.parse.urlparse(url)
return os.path.basename(parsed.path)
except Exception:
return ""
File diff suppressed because it is too large. Load diff
+558
View File
@@ -0,0 +1,558 @@
"""
Yuanbao sticker (TIMFaceElem) support.
Ported from yuanbao-openclaw-plugin/src/sticker/.
TIMFaceElem wire format:
{
"msg_type": "TIMFaceElem",
"msg_content": {
"index": 0, # always 0 per Yuanbao convention
"data": "<json>", # serialised sticker metadata
}
}
The `data` field carries a JSON string with the sticker's metadata so the
receiver can look up the correct asset in the emoji pack.
"""
from __future__ import annotations
import json
import random
import re
import unicodedata
from typing import Optional
# ---------------------------------------------------------------------------
# Sticker catalogue – ported from builtin-stickers.json
# Key : canonical name (Chinese)
# Value : {sticker_id, package_id, name, description, width, height, formats}
# ---------------------------------------------------------------------------
STICKER_MAP: dict[str, dict] = {
"六六六": {
"sticker_id": "278", "package_id": "1003", "name": "六六六",
"description": "666 厉害 牛 棒 绝了 好强 awesome",
"width": 128, "height": 128, "formats": "png",
},
"我想开了": {
"sticker_id": "262", "package_id": "1003", "name": "我想开了",
"description": "想开 佛系 释怀 顿悟 看淡了 无所谓",
"width": 128, "height": 128, "formats": "png",
},
"害羞": {
"sticker_id": "130", "package_id": "1003", "name": "害羞",
"description": "腼腆 不好意思 脸红 娇羞 羞涩 捂脸",
"width": 128, "height": 128, "formats": "png",
},
"比心": {
"sticker_id": "252", "package_id": "1003", "name": "比心",
"description": "笔芯 爱你 爱心手势 love heart 喜欢你",
"width": 128, "height": 128, "formats": "png",
},
"委屈": {
"sticker_id": "125", "package_id": "1003", "name": "委屈",
"description": "难过 想哭 可怜巴巴 瘪嘴 受伤 被欺负",
"width": 128, "height": 128, "formats": "png",
},
"亲亲": {
"sticker_id": "146", "package_id": "1003", "name": "亲亲",
"description": "么么 mua 亲一下 kiss 飞吻 啵",
"width": 128, "height": 128, "formats": "png",
},
"酷": {
"sticker_id": "131", "package_id": "1003", "name": "酷",
"description": "帅 墨镜 cool 高冷 有型 swagger",
"width": 128, "height": 128, "formats": "png",
},
"睡": {
"sticker_id": "145", "package_id": "1003", "name": "睡",
"description": "睡觉 困 zzZ 打盹 躺平 休眠 sleepy",
"width": 128, "height": 128, "formats": "png",
},
"发呆": {
"sticker_id": "152", "package_id": "1003", "name": "发呆",
"description": "懵 愣住 放空 呆滞 出神 脑子空白",
"width": 128, "height": 128, "formats": "png",
},
"可怜": {
"sticker_id": "157", "package_id": "1003", "name": "可怜",
"description": "卖萌 求饶 委屈巴巴 弱小 拜托 眼巴巴",
"width": 128, "height": 128, "formats": "png",
},
"摊手": {
"sticker_id": "200", "package_id": "1003", "name": "摊手",
"description": "无奈 没办法 耸肩 随便 那咋整 whatever",
"width": 128, "height": 128, "formats": "png",
},
"头大": {
"sticker_id": "213", "package_id": "1003", "name": "头大",
"description": "头疼 烦恼 郁闷 难搞 崩溃 一团乱",
"width": 128, "height": 128, "formats": "png",
},
"吓": {
"sticker_id": "256", "package_id": "1003", "name": "吓",
"description": "害怕 惊恐 震惊 吓一跳 恐怖 怂",
"width": 128, "height": 128, "formats": "png",
},
"吐血": {
"sticker_id": "203", "package_id": "1003", "name": "吐血",
"description": "无语 崩溃 被雷 内伤 一口老血 屮",
"width": 128, "height": 128, "formats": "png",
},
"哼": {
"sticker_id": "185", "package_id": "1003", "name": "哼",
"description": "傲娇 生气 不满 撇嘴 不理 赌气",
"width": 128, "height": 128, "formats": "png",
},
"嘿嘿": {
"sticker_id": "220", "package_id": "1003", "name": "嘿嘿",
"description": "坏笑 猥琐笑 偷笑 憨笑 得意 你懂的",
"width": 128, "height": 128, "formats": "png",
},
"头秃": {
"sticker_id": "218", "package_id": "1003", "name": "头秃",
"description": "程序员 加班 焦虑 没头发 秃了 肝爆",
"width": 128, "height": 128, "formats": "png",
},
"暗中观察": {
"sticker_id": "221", "package_id": "1003", "name": "暗中观察",
"description": "窥屏 潜水 偷偷看 角落 围观 屏住呼吸",
"width": 128, "height": 128, "formats": "png",
},
"我酸了": {
"sticker_id": "224", "package_id": "1003", "name": "我酸了",
"description": "嫉妒 柠檬精 羡慕 吃柠檬 眼红 恰柠檬",
"width": 128, "height": 128, "formats": "png",
},
"打call": {
"sticker_id": "246", "package_id": "1003", "name": "打call",
"description": "应援 加油 支持 喝彩 助威 call",
"width": 128, "height": 128, "formats": "png",
},
"庆祝": {
"sticker_id": "251", "package_id": "1003", "name": "庆祝",
"description": "祝贺 开心 耶 party 胜利 干杯",
"width": 128, "height": 128, "formats": "png",
},
"奋斗": {
"sticker_id": "151", "package_id": "1003", "name": "奋斗",
"description": "努力 加油 拼搏 冲 干劲 卷起来",
"width": 128, "height": 128, "formats": "png",
},
"惊讶": {
"sticker_id": "143", "package_id": "1003", "name": "惊讶",
"description": "震惊 哇 不敢相信 OMG 居然 这么离谱",
"width": 128, "height": 128, "formats": "png",
},
"疑问": {
"sticker_id": "144", "package_id": "1003", "name": "疑问",
"description": "问号 不懂 啥 为什么 啥情况 懵逼问",
"width": 128, "height": 128, "formats": "png",
},
"仔细分析": {
"sticker_id": "248", "package_id": "1003", "name": "仔细分析",
"description": "思考 推敲 认真 研究 琢磨 让我想想",
"width": 128, "height": 128, "formats": "png",
},
"撅嘴": {
"sticker_id": "184", "package_id": "1003", "name": "撅嘴",
"description": "嘟嘴 卖萌 不高兴 撒娇 嘴翘",
"width": 128, "height": 128, "formats": "png",
},
"泪奔": {
"sticker_id": "199", "package_id": "1003", "name": "泪奔",
"description": "大哭 伤心 破防 感动哭 泪流满面 呜呜",
"width": 128, "height": 128, "formats": "png",
},
"尊嘟假嘟": {
"sticker_id": "276", "package_id": "1003", "name": "尊嘟假嘟",
"description": "真的假的 真假 可爱问 你骗我 是不是",
"width": 128, "height": 128, "formats": "png",
},
"略略略": {
"sticker_id": "113", "package_id": "1003", "name": "略略略",
"description": "调皮 吐舌 不服 略 气死你 鬼脸",
"width": 128, "height": 128, "formats": "png",
},
"困": {
"sticker_id": "180", "package_id": "1003", "name": "困",
"description": "想睡 倦 打哈欠 睁不开眼 好困啊 sleepy",
"width": 128, "height": 128, "formats": "png",
},
"折磨": {
"sticker_id": "181", "package_id": "1003", "name": "折磨",
"description": "难受 痛苦 煎熬 蚌埠住了 受不了 要命",
"width": 128, "height": 128, "formats": "png",
},
"抠鼻": {
"sticker_id": "182", "package_id": "1003", "name": "抠鼻",
"description": "不屑 无聊 淡定 无所谓 鄙视 挖鼻",
"width": 128, "height": 128, "formats": "png",
},
"鼓掌": {
"sticker_id": "183", "package_id": "1003", "name": "鼓掌",
"description": "拍手 叫好 赞同 666 喝彩 掌声",
"width": 128, "height": 128, "formats": "png",
},
"斜眼笑": {
"sticker_id": "204", "package_id": "1003", "name": "斜眼笑",
"description": "滑稽 坏笑 doge 意味深长 阴阳怪气 嘿嘿嘿",
"width": 128, "height": 128, "formats": "png",
},
"辣眼睛": {
"sticker_id": "216", "package_id": "1003", "name": "辣眼睛",
"description": "看不下去 cringe 毁三观 太丑了 瞎了",
"width": 128, "height": 128, "formats": "png",
},
"哦哟": {
"sticker_id": "217", "package_id": "1003", "name": "哦哟",
"description": "惊讶 起哄 哇哦 有戏 不简单 哟",
"width": 128, "height": 128, "formats": "png",
},
"吃瓜": {
"sticker_id": "222", "package_id": "1003", "name": "吃瓜",
"description": "围观 看戏 八卦 路人 看热闹 板凳",
"width": 128, "height": 128, "formats": "png",
},
"狗头": {
"sticker_id": "225", "package_id": "1003", "name": "狗头",
"description": "doge 保命 开玩笑 滑稽 反讽 懂的都懂",
"width": 128, "height": 128, "formats": "png",
},
"敬礼": {
"sticker_id": "227", "package_id": "1003", "name": "敬礼",
"description": "salute 尊重 收到 遵命 致敬 报告",
"width": 128, "height": 128, "formats": "png",
},
"哦": {
"sticker_id": "231", "package_id": "1003", "name": "哦",
"description": "知道了 明白 敷衍 嗯 这样啊 收到",
"width": 128, "height": 128, "formats": "png",
},
"拿到红包": {
"sticker_id": "236", "package_id": "1003", "name": "拿到红包",
"description": "红包 谢谢老板 发财 开心 抢到了 欧气",
"width": 128, "height": 128, "formats": "png",
},
"牛吖": {
"sticker_id": "239", "package_id": "1003", "name": "牛吖",
"description": "牛 厉害 强 666 佩服 大佬",
"width": 128, "height": 128, "formats": "png",
},
"贴贴": {
"sticker_id": "272", "package_id": "1003", "name": "贴贴",
"description": "抱抱 亲昵 蹭蹭 亲密 靠靠 撒娇贴",
"width": 128, "height": 128, "formats": "png",
},
"爱心": {
"sticker_id": "138", "package_id": "1003", "name": "爱心",
"description": "心 love 喜欢你 红心 示爱 么么哒",
"width": 128, "height": 128, "formats": "png",
},
"晚安": {
"sticker_id": "170", "package_id": "1003", "name": "晚安",
"description": "好梦 睡了 night 早点休息 安啦 moon",
"width": 128, "height": 128, "formats": "png",
},
"太阳": {
"sticker_id": "176", "package_id": "1003", "name": "太阳",
"description": "晴天 早上好 阳光 morning 好天气 日",
"width": 128, "height": 128, "formats": "png",
},
"柠檬": {
"sticker_id": "266", "package_id": "1003", "name": "柠檬",
"description": "酸 嫉妒 柠檬精 羡慕 我酸 恰柠檬",
"width": 128, "height": 128, "formats": "png",
},
"大冤种": {
"sticker_id": "267", "package_id": "1003", "name": "大冤种",
"description": "倒霉 吃亏 自嘲 好心没好报 背锅 工具人",
"width": 128, "height": 128, "formats": "png",
},
"吐了": {
"sticker_id": "132", "package_id": "1003", "name": "吐了",
"description": "恶心 yue 受不了 嫌弃 想吐 生理不适",
"width": 128, "height": 128, "formats": "png",
},
"怒": {
"sticker_id": "134", "package_id": "1003", "name": "怒",
"description": "生气 愤怒 火大 暴躁 气炸 怼",
"width": 128, "height": 128, "formats": "png",
},
"玫瑰": {
"sticker_id": "165", "package_id": "1003", "name": "玫瑰",
"description": "花 示爱 表白 浪漫 送你花 情人节",
"width": 128, "height": 128, "formats": "png",
},
"凋谢": {
"sticker_id": "119", "package_id": "1003", "name": "凋谢",
"description": "花谢 失恋 难过 枯萎 心碎 凉了",
"width": 128, "height": 128, "formats": "png",
},
"点赞": {
"sticker_id": "159", "package_id": "1003", "name": "点赞",
"description": "赞 认同 好棒 good like 大拇指 顶",
"width": 128, "height": 128, "formats": "png",
},
"握手": {
"sticker_id": "164", "package_id": "1003", "name": "握手",
"description": "合作 你好 商务 hello deal 成交 友好",
"width": 128, "height": 128, "formats": "png",
},
"抱拳": {
"sticker_id": "163", "package_id": "1003", "name": "抱拳",
"description": "谢谢 失敬 江湖 承让 拜托 有礼",
"width": 128, "height": 128, "formats": "png",
},
"ok": {
"sticker_id": "169", "package_id": "1003", "name": "ok",
"description": "好的 收到 没问题 okay 行 可以 懂了",
"width": 128, "height": 128, "formats": "png",
},
"拳头": {
"sticker_id": "174", "package_id": "1003", "name": "拳头",
"description": "加油 干 冲 fight 力量 击拳 硬气",
"width": 128, "height": 128, "formats": "png",
},
"鞭炮": {
"sticker_id": "191", "package_id": "1003", "name": "鞭炮",
"description": "过年 喜庆 爆竹 春节 噼里啪啦 红",
"width": 128, "height": 128, "formats": "png",
},
"烟花": {
"sticker_id": "258", "package_id": "1003", "name": "烟花",
"description": "庆典 漂亮 新年 嘭 绽放 节日快乐",
"width": 128, "height": 128, "formats": "png",
},
}
def get_sticker_by_name(name: str) -> Optional[dict]:
"""
按名称查找贴纸,支持模糊匹配。
匹配优先级:
1. 完全相等(name)
2. name 包含查询词(前缀/子串)
3. description 包含查询词(同义词搜索)
4. 通用模糊评分(与 sticker-search 同算法),命中即返回得分最高的一条
返回 sticker dict,找不到返回 None。
"""
if not name:
return None
query = name.strip()
if query in STICKER_MAP:
return STICKER_MAP[query]
for key, sticker in STICKER_MAP.items():
if query in key or key in query:
return sticker
for sticker in STICKER_MAP.values():
desc = sticker.get("description", "")
if query in desc:
return sticker
matches = search_stickers(query, limit=1)
return matches[0] if matches else None
def get_random_sticker(category: str = None) -> dict:
"""
随机返回一个贴纸。
若指定 category,则在 description 中含有该关键词的贴纸里随机选取;
category 为 None 时从全表随机。
"""
if category:
candidates = [
s for s in STICKER_MAP.values()
if category in s.get("description", "") or category in s.get("name", "")
]
if candidates:
return random.choice(candidates)
return random.choice(list(STICKER_MAP.values()))
def get_sticker_by_id(sticker_id: str) -> Optional[dict]:
"""按 sticker_id 精确查找贴纸。"""
if not sticker_id:
return None
sid = str(sticker_id).strip()
for sticker in STICKER_MAP.values():
if sticker.get("sticker_id") == sid:
return sticker
return None
# ---------------------------------------------------------------------------
# 模糊搜索(对齐 chatbot-web yuanbao-openclaw-plugin/sticker-cache.ts.searchStickers)
# ---------------------------------------------------------------------------
_PUNCT_RE = re.compile(r"[\s\u3000\-_·.,,。!!??\"“”'‘’、/\\]+")
def _normalize_text(raw: str) -> str:
return unicodedata.normalize("NFKC", str(raw or "")).strip().lower()
def _compact_text(raw: str) -> str:
return _PUNCT_RE.sub("", _normalize_text(raw))
def _multiset_char_hit_ratio(needle: str, haystack: str) -> float:
if not needle:
return 0.0
bag: dict[str, int] = {}
for ch in haystack:
bag[ch] = bag.get(ch, 0) + 1
hits = 0
for ch in needle:
n = bag.get(ch, 0)
if n > 0:
hits += 1
bag[ch] = n - 1
return hits / len(needle)
def _bigram_jaccard(a: str, b: str) -> float:
if len(a) < 2 or len(b) < 2:
return 0.0
A = {a[i:i + 2] for i in range(len(a) - 1)}
B = {b[i:i + 2] for i in range(len(b) - 1)}
inter = len(A & B)
union = len(A) + len(B) - inter
return inter / union if union else 0.0
def _longest_subsequence_ratio(needle: str, haystack: str) -> float:
if not needle:
return 0.0
j = 0
for ch in haystack:
if j >= len(needle):
break
if ch == needle[j]:
j += 1
return j / len(needle)
def _score_field(haystack: str, query: str) -> float:
hay = _normalize_text(haystack)
q = _normalize_text(query)
if not hay or not q:
return 0.0
hay_c = _compact_text(haystack)
q_c = _compact_text(query)
best = 0.0
if hay == q:
best = max(best, 100.0)
if q in hay:
best = max(best, 92 + min(6, len(q)))
if len(q) >= 2 and hay.startswith(q):
best = max(best, 88.0)
if q_c and q_c in hay_c:
best = max(best, 86.0)
best = max(best, _multiset_char_hit_ratio(q_c, hay_c) * 62)
best = max(best, _bigram_jaccard(q_c, hay_c) * 58)
best = max(best, _longest_subsequence_ratio(q_c, hay_c) * 52)
if len(q) == 1 and q in hay:
best = max(best, 68.0)
return best
def search_stickers(query: str, limit: int = 10) -> list[dict]:
"""
在内置贴纸表中按模糊匹配排序返回前 N 条结果。
评分综合 name/description 字段的子串、字符多重集覆盖、bigram Jaccard、子序列比例。
name 权重略高于 description(×0.88)。空 query 时按字典顺序返回前 N 条。
"""
safe_limit = max(1, min(500, int(limit) if limit else 10))
if not query or not _normalize_text(query):
return list(STICKER_MAP.values())[:safe_limit]
scored: list[tuple[float, dict]] = []
for sticker in STICKER_MAP.values():
name_s = _score_field(sticker.get("name", ""), query)
desc_s = _score_field(sticker.get("description", ""), query) * 0.88
sid = str(sticker.get("sticker_id", "")).strip()
q_norm = _normalize_text(query)
id_s = 0.0
if sid and q_norm:
sid_norm = _normalize_text(sid)
if sid_norm == q_norm:
id_s = 100.0
elif q_norm in sid_norm:
id_s = 84.0
scored.append((max(name_s, desc_s, id_s), sticker))
scored.sort(key=lambda x: x[0], reverse=True)
top = scored[0][0] if scored else 0
if top <= 0:
return [s for _, s in scored[:safe_limit]]
if top >= 22:
floor = 18.0
elif top >= 12:
floor = max(10.0, top * 0.5)
else:
floor = max(6.0, top * 0.35)
filtered = [pair for pair in scored if pair[0] >= floor]
out = filtered if filtered else scored
return [s for _, s in out[:safe_limit]]
def build_face_msg_body(
face_index: int,
face_type: int = 1,
data: Optional[str] = None,
) -> list:
"""
构造 TIMFaceElem 消息体。
Yuanbao 约定:
- index 固定传 0(服务端通过 data 字段识别具体表情)
- data 为 JSON 字符串,包含 sticker_id / package_id 等字段
Args:
face_index: 保留字段,暂时不影响 wire format(Yuanbao 固定 index=0)。
当 face_index > 0 时视为旧版 QQ 表情 ID,直接放入 index。
face_type: 保留字段(兼容旧接口,当前未使用)。
data: 已序列化的 JSON 字符串;为 None 时仅传 index。
Returns:
符合 Yuanbao TIM 协议的 msg_body list,如::
[{"msg_type": "TIMFaceElem", "msg_content": {"index": 0, "data": "..."}}]
"""
msg_content: dict = {"index": face_index}
if data is not None:
msg_content["data"] = data
return [{"msg_type": "TIMFaceElem", "msg_content": msg_content}]
def build_sticker_msg_body(sticker: dict) -> list:
"""
从 STICKER_MAP 中的 sticker dict 直接构造 TIMFaceElem 消息体。
这是 send_sticker() 的内部辅助,确保 data 字段与原始 JS 插件一致。
"""
data_payload = json.dumps(
{
"sticker_id": sticker["sticker_id"],
"package_id": sticker["package_id"],
"width": sticker.get("width", 128),
"height": sticker.get("height", 128),
"formats": sticker.get("formats", "png"),
"name": sticker["name"],
},
ensure_ascii=False,
separators=(",", ":"),
)
return build_face_msg_body(face_index=0, data=data_payload)
+246
View File
@@ -0,0 +1,246 @@
"""Profile-based routing for the gateway with hierarchical matching.
Allows a single Hermes instance to route specific Discord guilds/channels/threads
to different profiles — each with their own model, tools, memory, and persona.
Matching priority (most specific first):
1. platform + chat_id + thread_id (exact thread) — specificity 14
2. platform + chat_id (channel route) — specificity 6
3. platform + guild_id (guild/server route) — specificity 2
4. No match → default profile
Parent-chain matching:
For Discord threads and forum posts, ``parent_chat_id`` carries the
direct parent (the channel for a thread, the forum channel for a post).
Routes keyed on a channel match both direct messages and messages in
any thread/post whose parent is that channel.
Configuration (config.yaml):
gateway:
profile_routes:
- name: server-default
platform: discord
guild_id: "YOUR_GUILD_ID"
profile: server-profile
- name: special-channel
platform: discord
guild_id: "YOUR_GUILD_ID"
chat_id: "YOUR_CHANNEL_ID"
profile: channel-profile
- name: thread-route
platform: discord
chat_id: "YOUR_CHANNEL_ID"
thread_id: "YOUR_THREAD_ID"
profile: thread-profile
- name: owner-whatsapp
platform: whatsapp
chat_id: "15551234567" # phone, JID, or LID — all equivalent
profile: owner
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import logging
logger = logging.getLogger(__name__)
# Baileys and Cloud share phone/JID/LID identity rules. Other platforms keep
# exact string compare so Telegram numeric ids and Discord snowflakes stay
# unchanged.
_WHATSAPP_IDENTITY_PLATFORMS = {"whatsapp", "whatsapp_cloud"}
_WHATSAPP_NON_USER_SUFFIXES = ("@g.us", "@broadcast", "@newsletter")
def _is_whatsapp_non_user_chat(chat_id: Optional[str]) -> bool:
"""True for group / broadcast / newsletter JIDs — not a sender identity."""
if not chat_id:
return False
cid = str(chat_id).strip().lower()
return any(cid.endswith(suffix) for suffix in _WHATSAPP_NON_USER_SUFFIXES)
def _whatsapp_user_chat_ids_match(platform: str, left: Optional[str], right: Optional[str]) -> bool:
"""True when two WhatsApp *user* chat_ids refer to the same person.
Reuses :func:`gateway.whatsapp_identity.expand_whatsapp_aliases` so a
bare phone number, a ``@s.whatsapp.net`` JID, and a ``@lid`` LID collapse
to one identity — the same helper session keys and adapter allowlists
already use. Group/broadcast JIDs are excluded: those are chats, not
senders. Returns False for non-WhatsApp platforms (exact match only).
"""
if (platform or "").strip().lower() not in _WHATSAPP_IDENTITY_PLATFORMS:
return False
if not left or not right:
return False
if _is_whatsapp_non_user_chat(left) or _is_whatsapp_non_user_chat(right):
return False
from gateway.whatsapp_identity import expand_whatsapp_aliases
left_aliases = expand_whatsapp_aliases(str(left))
if not left_aliases:
return False
return bool(left_aliases & expand_whatsapp_aliases(str(right)))
class ProfileRouteRejected(RuntimeError):
"""An explicit route matched a profile this gateway does not serve."""
@dataclass(frozen=True)
class ProfileRoute:
"""A single routing rule that maps a platform scope to a profile."""
name: str
platform: str
profile: str
guild_id: Optional[str] = None
chat_id: Optional[str] = None
thread_id: Optional[str] = None
enabled: bool = True
@property
def specificity(self) -> int:
"""Higher value = more specific match."""
s = 0
if self.guild_id:
s += 2
if self.chat_id:
s += 4
if self.thread_id:
s += 8
return s
def matches(
self,
platform: str,
guild_id: Optional[str] = None,
chat_id: Optional[str] = None,
thread_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
) -> bool:
"""Return True if this route matches the given source fields.
All configured discriminators are matched conjunctively (AND): every
discriminator that the route declares must hold. ``chat_id`` supports
hierarchical matching for Discord forums/threads:
- Direct channel match: chat_id == route.chat_id
- Thread in channel: parent_chat_id == route.chat_id
A route declaring both ``guild_id`` and ``chat_id`` requires both to
match (a chat match alone does not satisfy a guild constraint).
WhatsApp / WhatsApp Cloud ``chat_id`` also matches across user-identity
forms (bare number, JID, LID) after the exact-string check. Exact
matches always win first, so existing configs keep working. Groups
(``@g.us``) and broadcasts stay exact-only.
"""
if not self.enabled:
return False
if self.platform != platform:
return False
if self.thread_id and self.thread_id != thread_id:
return False
if self.chat_id and self.chat_id != chat_id and self.chat_id != parent_chat_id:
if not (
_whatsapp_user_chat_ids_match(platform, self.chat_id, chat_id)
or _whatsapp_user_chat_ids_match(platform, self.chat_id, parent_chat_id)
):
return False
if self.guild_id and self.guild_id != guild_id:
return False
return True
def _coerce_route_id(value: Any) -> Optional[str]:
"""Normalize a route discriminator to str for strict equality matching.
PyYAML loads unquoted numeric IDs (Discord snowflakes, Telegram negative
chat ids) as ``int``. Inbound ``SessionSource`` fields are always ``str``
via ``build_source``, so leaving ints here makes ``matches()`` fail silently.
Only ``int`` is coerced (the legitimate YAML-numeric case). ``bool`` is an
``int`` subclass but never a valid id; floats and other types stringify to
something (``"123.0"``) that can never equal an inbound id — recreating the
silent no-match this exists to fix — so they are passed through with a
load-time warning instead of being silently "fixed" (#86470).
"""
if value is None or isinstance(value, str):
return value
if isinstance(value, int) and not isinstance(value, bool):
return str(value)
logger.warning(
"Profile route discriminator %r (type %s) can never match an inbound "
"id — quote it in config.yaml (e.g. chat_id: \"%s\").",
value, type(value).__name__, value,
)
return str(value)
def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRoute]:
"""Parse profile_routes from config.yaml into ProfileRoute objects.
Returns routes sorted by specificity (most specific first).
"""
if not raw:
return []
routes: List[ProfileRoute] = []
for entry in raw:
if not isinstance(entry, dict):
continue
name = entry.get("name", "")
platform = entry.get("platform", "")
profile = entry.get("profile", "")
if not platform or not profile:
logger.warning(
"Skipping profile route %s: missing platform or profile",
name,
)
continue
# Validate profile name to prevent path traversal. Lazy import avoids a
# circular dependency at module load time.
try:
from hermes_cli.profiles import (
normalize_profile_name,
validate_profile_name,
)
profile = normalize_profile_name(profile)
validate_profile_name(profile)
except (ValueError, ImportError):
logger.warning("Skipping profile route %s: invalid profile name %r", name, profile)
continue
routes.append(
ProfileRoute(
name=name,
platform=platform,
profile=profile,
guild_id=_coerce_route_id(entry.get("guild_id")),
chat_id=_coerce_route_id(entry.get("chat_id")),
thread_id=_coerce_route_id(entry.get("thread_id")),
enabled=entry.get("enabled", True),
)
)
# Sort: most specific first so the first match wins.
routes.sort(key=lambda r: r.specificity, reverse=True)
logger.debug("Loaded %d profile routes (most-specific-first)", len(routes))
return routes
def match_profile_route(
routes: List[ProfileRoute],
platform: str,
guild_id: Optional[str] = None,
chat_id: Optional[str] = None,
thread_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
) -> Optional[ProfileRoute]:
"""Return the best-matching route, or None for no match."""
for route in routes:
if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id):
return route
return None
+138
View File
@@ -0,0 +1,138 @@
"""Bounded, non-destructive readiness probes for authenticated health surfaces."""
from __future__ import annotations
import shutil
import sqlite3
from contextlib import closing
from pathlib import Path
from typing import Any
import yaml
from hermes_constants import get_hermes_home
_DISK_DEGRADED_PERCENT = 90.0
def _check(status: str, detail: str | None = None, **extra: Any) -> dict[str, Any]:
result: dict[str, Any] = {"status": status}
if detail:
result["detail"] = detail
result.update(extra)
return result
def _probe_state_db(home: Path) -> dict[str, Any]:
path = home / "state.db"
if not path.exists():
return _check("ok", "not initialized")
try:
# A readiness probe must never compete with normal state writers. A
# read-only schema query still catches unreadable/corrupt databases
# without taking a write reservation on every health poll.
# ``closing(...)`` is required: sqlite3's connection context manager
# only commits/rolls back — it never closes, so a bare ``with
# sqlite3.connect(...)`` leaks one connection (and its fds) per
# health poll in the long-running gateway (#69678/#69567 bug class).
uri = f"file:{path.as_posix()}?mode=ro"
with closing(sqlite3.connect(uri, uri=True, timeout=1.0)) as conn:
conn.execute("PRAGMA query_only = ON")
conn.execute("SELECT name FROM sqlite_master LIMIT 1").fetchone()
return _check("ok")
except Exception as exc:
return _check("degraded", type(exc).__name__)
def _probe_config(home: Path) -> dict[str, Any]:
path = home / "config.yaml"
if not path.exists():
return _check("ok", "using defaults")
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
if raw is not None and not isinstance(raw, dict):
return _check("degraded", "top level is not a mapping")
return _check("ok")
except Exception as exc:
return _check("degraded", f"invalid config ({type(exc).__name__})")
def _probe_disk(home: Path) -> dict[str, Any]:
try:
usage = shutil.disk_usage(home)
used_pct = round((usage.used / usage.total) * 100, 1) if usage.total else 0.0
status = "degraded" if used_pct >= _DISK_DEGRADED_PERCENT else "ok"
return _check(status, used_percent=used_pct, free_bytes=usage.free)
except Exception as exc:
return _check("degraded", type(exc).__name__)
def _probe_gateway(runtime_status: dict[str, Any]) -> dict[str, Any]:
state = str(runtime_status.get("gateway_state") or "unknown")
platforms = runtime_status.get("platforms")
connected = 0
configured = 0
if isinstance(platforms, dict):
configured = len(platforms)
connected = sum(
1
for value in platforms.values()
if isinstance(value, dict)
and str(value.get("state") or value.get("status") or "").lower()
in {"connected", "running", "ok"}
)
status = "ok" if state in {"running", "draining"} else "degraded"
return _check(status, state=state, connected_platforms=connected, platforms=configured)
def _probe_session_store(
runtime_status: dict[str, Any], state_db_probe: dict[str, Any]
) -> dict[str, Any]:
"""Report the running gateway cache state, not an independent reopen."""
runtime_store = runtime_status.get("session_store")
if isinstance(runtime_store, dict):
state = str(runtime_store.get("status") or "unknown")
if state in {"ok", "unavailable", "retrying"}:
return _check(state)
# Older gateways do not publish a cache state. Preserve their readiness
# behavior until their process restarts onto a version that does.
return _check("ok" if state_db_probe.get("status") == "ok" else "unavailable")
def collect_runtime_readiness(
*,
configured_model: str,
runtime_status: dict[str, Any] | None,
active_api_runs: int = 0,
process_completion_queue_depth: int = 0,
active_delegations: int = 0,
) -> dict[str, Any]:
"""Return bounded readiness diagnostics without mutating runtime state.
The detailed health endpoint is authenticated. Even there, probes expose
status and counts only: never config values, credentials, paths, commands,
queue payloads, or exception messages.
"""
home = get_hermes_home()
runtime = runtime_status if isinstance(runtime_status, dict) else {}
state_db_probe = _probe_state_db(home)
checks = {
"state_db": state_db_probe,
"session_store": _probe_session_store(runtime, state_db_probe),
"config": _probe_config(home),
"model": _check("ok" if str(configured_model or "").strip() else "degraded"),
"disk": _probe_disk(home),
"gateway": _probe_gateway(runtime),
"background_queues": _check(
"ok",
active_api_runs=max(0, int(active_api_runs)),
process_completions=max(0, int(process_completion_queue_depth)),
active_delegations=max(0, int(active_delegations)),
),
}
overall = "ok" if all(item.get("status") == "ok" for item in checks.values()) else "degraded"
return {"status": overall, "checks": checks}
__all__ = ["collect_runtime_readiness"]
+967
View File
@@ -0,0 +1,967 @@
"""Relay/connector support package for the Hermes gateway.
EXPERIMENTAL. This package implements the gateway side of the "Gateway Gateway"
relay design: a generic ``RelayAdapter`` plus the wire-serializable
``CapabilityDescriptor`` the connector hands it at handshake time, and the
production ``WebSocketRelayTransport`` that dials the connector. The public API
(module names, descriptor field set, transport protocol) MAY CHANGE without a
deprecation cycle until at least two real Class-1 platforms (Discord + Telegram)
have shaken out the schema.
See ``docs/relay-connector-contract.md`` for the formal cross-repo interface.
Activation is driven by configuration, not a separate feature flag: the relay
platform is registered when a connector relay URL is configured
(``GATEWAY_RELAY_URL`` env or ``gateway.relay_url`` in config.yaml). Deployments
that don't set it are unaffected — exactly the same shape as ``gateway.proxy_url``.
"""
from __future__ import annotations
import os
import re
from typing import Optional
# Shape gate for ambient-endpoint token bodies (mode 1b in
# _resolve_relay_identity_token). Accepts a bearer-token-shaped string:
# either a multi-segment dotted token (JWT: header.payload.signature) or a
# single long opaque token (>= 32 chars of the base64url alphabet). Short
# bare words — 'unauthorized', 'error', 'null' — match the alphabet but are
# plain-text error bodies, not credentials, and must fail closed.
_AMBIENT_TOKEN_SHAPE = re.compile(
r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}" # JWT-like: 3+ dotted segments
r"|[A-Za-z0-9_-]{32,}" # long opaque bearer token
)
def relay_url() -> Optional[str]:
"""The connector relay endpoint URL, or None when relay is not configured.
Checks ``GATEWAY_RELAY_URL`` (convenient for Docker) first, then
``gateway.relay_url`` in config.yaml. A non-empty value activates the relay
platform; absence means a normal direct/single-tenant gateway.
"""
url = os.environ.get("GATEWAY_RELAY_URL", "").strip()
if url:
return url.rstrip("/")
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = _load_gateway_config()
url = (cfg.get("gateway") or {}).get("relay_url")
url = (url or "").strip()
if url:
return url.rstrip("/")
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
pass
return None
def relay_platform_identities() -> list[tuple[str, str]]:
"""The (platform, bot_id) pairs this gateway fronts over the relay (Phase 1.5).
Shape A (multi-platform-per-agent, D-Q1.5c — CUT OVER, no scalar fallback):
one gateway fronts a SET of platforms on one WS connection. The set is the
env-stamped deploy config:
- ``GATEWAY_RELAY_PLATFORMS`` — comma-sep list (e.g. ``discord,telegram``).
- ``GATEWAY_RELAY_BOT_IDS`` — JSON keyed map
``{"discord": {"botId": "..."}, "telegram": {"botId": "...", "username": "..."}}``.
Returns the ordered list of ``(platform, bot_id)`` pairs (the FIRST is the
default the handshake/descriptor falls back to). The connector accepts N
hellos accumulating into its advertised set; outbound frames discriminate
per-frame on the platform (gateway-gateway D-Q1.5b.1). A platform present in
the list but absent from the ids map resolves with an empty bot_id (the
connector rejects an unprovisioned platform with a structured failure).
Defaults to ``[("relay", "")]`` when nothing is configured (the generic
single-plane fallback for a connector that didn't stamp a platform set).
"""
platforms_raw = os.environ.get("GATEWAY_RELAY_PLATFORMS", "").strip()
platforms = [p.strip() for p in platforms_raw.split(",") if p.strip()]
if not platforms:
return [("relay", "")]
ids = _relay_bot_ids_map()
out: list[tuple[str, str]] = []
for platform in platforms:
entry = ids.get(platform) or {}
bot_id = str(entry.get("botId", "")).strip() if isinstance(entry, dict) else ""
out.append((platform, bot_id))
return out
def relay_fronted_platforms() -> set[str]:
"""The logical platform names the relay connector fronts for this gateway.
Thin, env-derived wrapper over :func:`relay_platform_identities` (the
``GATEWAY_RELAY_PLATFORMS`` deploy stamp) minus the generic ``relay``
fallback. This is the SAME source ``ws_transport`` seeds the live
adapter's identity set from (``RelayAdapter.fronts_platform``), so
config-time validation (e.g. cron delivery preflight) and fire-time
routing can never disagree — and it needs no live adapter handle, so a
standalone scheduler process can consult it too. Empty set when the
relay fronts nothing.
"""
return {p for p, _ in relay_platform_identities() if p != "relay"}
def _relay_bot_ids_map() -> dict:
"""Parse ``GATEWAY_RELAY_BOT_IDS`` (JSON keyed map). Never raises — a malformed
map yields ``{}`` so a bad config degrades to empty bot ids (the connector
rejects an unprovisioned platform) rather than crashing boot."""
import json
import logging
raw = os.environ.get("GATEWAY_RELAY_BOT_IDS", "").strip()
if not raw:
return {}
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception: # noqa: BLE001 - a bad map must not crash boot
logging.getLogger("gateway.relay").warning(
"GATEWAY_RELAY_BOT_IDS is not valid JSON; treating as empty"
)
return {}
def relay_bot_username(platform: str) -> Optional[str]:
"""The bot's deep-link username/handle for a platform (e.g. Telegram's
``@handle`` for ``t.me/<handle>``), read from the per-platform entry in
``GATEWAY_RELAY_BOT_IDS``. None when absent (most platforms don't need one).
"""
entry = _relay_bot_ids_map().get(platform)
if isinstance(entry, dict):
username = entry.get("username")
if username:
return str(username).lstrip("@")
return None
def relay_platform_identity() -> tuple[str, str]:
"""The PRIMARY (platform, bot_id) — the first identity in the configured set.
Kept for call sites that need a single representative identity (the default
descriptor platform, the policy projection's primary). The full set is
``relay_platform_identities()``. Defaults to ``("relay", "")``.
"""
return relay_platform_identities()[0]
def relay_connection_auth() -> tuple[Optional[str], Optional[str]]:
"""The (gateway_id, upgrade_secret) this gateway authenticates the WS upgrade with.
Both come from enrollment (``hermes gateway enroll`` writes them to
``~/.hermes/.env``): ``GATEWAY_RELAY_ID`` identifies the enrolled instance,
``GATEWAY_RELAY_SECRET`` is the per-gateway signing secret. Either absent ->
``(None, None)`` and the transport dials unauthenticated (dev/test, or a
connector that doesn't enforce auth). Checks env first (Docker), then
``gateway.relay_id`` / ``gateway.relay_secret`` in config.yaml.
"""
gateway_id = os.environ.get("GATEWAY_RELAY_ID", "").strip()
secret = os.environ.get("GATEWAY_RELAY_SECRET", "").strip()
if not (gateway_id and secret):
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
gateway_id = gateway_id or str(cfg.get("relay_id", "") or "").strip()
secret = secret or str(cfg.get("relay_secret", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
pass
return (gateway_id or None, secret or None)
def relay_endpoint() -> Optional[str]:
"""The gateway's own PUBLIC inbound URL, asserted to the connector at provision.
The connector delivers signed inbound POSTs to this URL and stores it on the
tenant's route rows. It is gateway-asserted (the connector scopes it to the
verified tenant, so a dishonest gateway can only misdirect its OWN inbound).
The *source* of the value differs by deployment but the code path is uniform:
a self-hosted operator sets ``GATEWAY_RELAY_ENDPOINT`` (mirrors how they set
``HERMES_DASHBOARD_PUBLIC_URL``); a hosted/NAS container has the same var
stamped in (NAS knows the public URL only in that case). Absent -> the
gateway provisions outbound-only (no inbound routes written).
Env first (Docker), then ``gateway.relay_endpoint`` in config.yaml.
"""
url = os.environ.get("GATEWAY_RELAY_ENDPOINT", "").strip()
if not url:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
url = str(cfg.get("relay_endpoint", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash boot
url = ""
return url.rstrip("/") or None
def relay_route_keys() -> list[str]:
"""Discriminators (scope_ids / chat_ids / paths) this gateway's tenant owns.
Gateway-provided config, paired with ``relay_endpoint()``: the connector
writes one route row per (routeKey -> tenant, endpoint), so route keys only
take effect alongside an endpoint. Empty -> outbound-only provisioning (the
connector accepts an empty set and writes no route rows).
``GATEWAY_RELAY_ROUTE_KEYS`` is comma-separated; config.yaml
``gateway.relay_route_keys`` may be a list or a comma string.
"""
raw = os.environ.get("GATEWAY_RELAY_ROUTE_KEYS", "").strip()
if not raw:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
val = cfg.get("relay_route_keys", "")
if isinstance(val, (list, tuple)):
return [str(k).strip() for k in val if str(k).strip()]
raw = str(val or "").strip()
except Exception: # noqa: BLE001
raw = ""
return [k.strip() for k in raw.split(",") if k.strip()]
def relay_instance_id() -> Optional[str]:
"""Stable per-instance id this gateway forwards at provision (Phase 6 Unit α).
Binds the connector's ``gatewayId -> instanceId`` so the connector can route
inbound per-instance (not tenant-broadcast) once Phase 6 delivery lands. The
value is the NAS ``AgentInstance.id`` for a managed agent (NAS stamps
``GATEWAY_RELAY_INSTANCE_ID`` into the container env, beside
``GATEWAY_RELAY_URL``); a self-hosted operator may set it explicitly. It is
gateway-asserted but safely scoped: the org/tenant stays token-verified, so a
dishonest gateway can only bind ITS OWN tenant's instance — the same posture
as ``relay_endpoint()``. Absent -> the connector stores null and per-instance
routing simply has no binding for this connection yet (back-compat).
Env first (Docker/NAS), then ``gateway.relay_instance_id`` in config.yaml.
"""
value = os.environ.get("GATEWAY_RELAY_INSTANCE_ID", "").strip()
if not value:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
value = str(cfg.get("relay_instance_id", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash boot
value = ""
return value or None
def relay_wake_url() -> Optional[str]:
"""The gateway's WAKE URL, forwarded at provision (Phase 5 §5.2 wake PRIMITIVE).
A poke target the connector issues a payload-free GET to when a buffered-only
(going-idle) destination for this instance receives its first buffered event,
so a suspended gateway wakes, reconnects its relay WS, and drains its
delivery-leg backlog. The value's *source* differs by deployment but the code
path is uniform: a managed/NAS container has ``GATEWAY_RELAY_WAKE_URL`` stamped
in (NAS knows the Fly autostart / dashboard hostname); a self-hosted operator
sets it explicitly (or passes ``--wake-url`` to ``hermes gateway enroll``).
Gateway-asserted but safely scoped: the org/tenant stays token-verified, so a
dishonest gateway can only register a wake target for ITS OWN instance — the
same posture as ``relay_instance_id()`` / the retired ``relay_endpoint()``.
Absent -> the connector stores null and simply can't wake this instance
(buffering still works; the gateway drains whenever it next reconnects).
Env first (Docker/NAS), then ``gateway.relay_wake_url`` in config.yaml.
"""
value = os.environ.get("GATEWAY_RELAY_WAKE_URL", "").strip()
if not value:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
value = str(cfg.get("relay_wake_url", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash boot
value = ""
return value.rstrip("/") or None
def relay_display_name() -> Optional[str]:
"""The human-facing agent display name, forwarded at provision (Phase 1 parity).
The PRIMARY source for the connector's multi-agent reply-attribution prefix
(gateway-gateway #171): in a multi-agent scope the shared bot prepends
``**<displayName>:** `` to this instance's replies. Gateway-asserted but
safely scoped exactly like ``relay_instance_id()`` / ``relay_wake_url()`` —
the tenant stays token-verified, so a dishonest gateway can only label its
OWN instance. Absent -> the connector stores null and attribution falls
back to the instance's linked-owner identity, else skips the prefix.
Env first (Docker/NAS stamps ``GATEWAY_RELAY_DISPLAY_NAME``), then the
skin's branded agent name (``get_branding("agent_name")`` — the same value
the CLI banner shows), so a self-hosted rename via skin config propagates
on the next boot's re-provision (the connector rotates on change, same as
a wake-url move).
"""
value = os.environ.get("GATEWAY_RELAY_DISPLAY_NAME", "").strip()
if not value:
try:
from hermes_cli.skin_engine import get_active_skin # late import: boot-safe
value = str(
get_active_skin().get_branding("agent_name", "") or ""
).strip()
except Exception: # noqa: BLE001 - branding absence must never crash boot
value = ""
# The stock brand name is IDENTICAL on every default install, so in a
# multi-agent scope it would prefix every reply "**Hermes Agent:**" —
# shadowing the connector's linked-owner fallback, which actually
# disambiguates. Only a deliberately customized name is forwarded.
if value == "Hermes Agent":
value = ""
# Mirror the connector's ingest sanitization (trim + 64-char cap) so what
# we send is what gets stored.
return value[:64] or None
def _provision_url(relay_dial_url: str) -> str:
"""Map the ``ws(s)://…/relay`` dial URL to the ``http(s)://…/relay/provision`` POST URL."""
raw = relay_dial_url.rstrip("/")
if raw.startswith("ws://"):
raw = "http://" + raw[len("ws://"):]
elif raw.startswith("wss://"):
raw = "https://" + raw[len("wss://"):]
if raw.endswith("/relay"):
raw = raw[: -len("/relay")]
return f"{raw}/relay/provision"
def _policy_url(relay_dial_url: str) -> str:
"""Map the ``ws(s)://…/relay`` dial URL to the ``http(s)://…/relay/policy`` POST URL.
Same host derivation as ``_provision_url``; the connector mounts the
relevance-policy update channel at ``/relay/policy`` (Phase 6 Unit ζ).
"""
raw = relay_dial_url.rstrip("/")
if raw.startswith("ws://"):
raw = "http://" + raw[len("ws://"):]
elif raw.startswith("wss://"):
raw = "https://" + raw[len("wss://"):]
if raw.endswith("/relay"):
raw = raw[: -len("/relay")]
return f"{raw}/relay/policy"
def relay_relevance_policy(platform: Optional[str] = None) -> Optional[dict]:
"""Project a fronted platform's RELEVANCE config into the connector's generic vocabulary.
The connector's relevance gate (Phase 6 Unit ζ) reasons over a
platform-agnostic policy — ``requireAddress`` / ``freeResponseScopes`` /
``allowOtherBots`` — NOT over Discord/Telegram words. This is the gateway
side of that contract: it reads the agent's existing relevance knobs and
emits the generic shape the connector stores per-instance (Phase 1.5: the
connector keys the policy by ``(tenant, platform, instanceId)``, so each
fronted platform gets its own row — pass its name here).
Mapping (the connector vocabulary ← the gateway's existing config):
- ``requireAddress`` ← the platform's ``require_mention`` (the agent
only engages a non-owner message that @mentions it / replies to it).
- ``freeResponseScopes`` ← the platform's ``free_response_channels`` (the
channel/scope ids where ``require_mention`` is waived — same scope
vocabulary the connector's δ scope grants + ε floor use).
- ``allowOtherBots`` ← ``{PLATFORM}_ALLOW_BOTS`` in {"mentions","all"}
(whether bot-authored messages are admitted; default off).
Read from the relay platform's config block (the platform the connector
fronts, e.g. ``discord:``), falling back to the bridged top-level keys, then
the ``{PLATFORM}_*`` env. ``platform`` defaults to the PRIMARY fronted
platform (back-compat). Returns the generic dict, or None when relay isn't
configured or the platform exposes no relevance knobs (⇒ the connector's
default — mention-gated — applies unchallenged; an EXPLICIT
``require_mention: false`` IS a knob and is declared so the connector
doesn't mention-gate an agent configured to free-respond).
"""
if platform is None:
platform, _bot_id = relay_platform_identity()
if not platform or platform == "relay":
# No concrete fronted platform resolved ⇒ nothing platform-specific to project.
return None
# Resolve the platform's config block + the bridged top-level keys.
require_mention = None
free_response: list[str] = []
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = _load_gateway_config() or {}
plat_cfg = cfg.get(platform)
if not isinstance(plat_cfg, dict):
_gw_platforms = (cfg.get("gateway") or {}).get("platforms") or {}
if not isinstance(_gw_platforms, dict):
_gw_platforms = {}
plat_cfg = _gw_platforms.get(platform)
if not isinstance(plat_cfg, dict):
plat_cfg = (cfg.get("platforms") or {}).get(platform)
plat_cfg = plat_cfg if isinstance(plat_cfg, dict) else {}
if "require_mention" in plat_cfg:
require_mention = plat_cfg.get("require_mention")
elif cfg.get("require_mention") is not None:
require_mention = cfg.get("require_mention")
frc = plat_cfg.get("free_response_channels")
if frc is None:
frc = cfg.get("free_response_channels")
if isinstance(frc, (list, tuple)):
free_response = [str(c).strip() for c in frc if str(c).strip()]
elif isinstance(frc, str) and frc.strip():
free_response = [c.strip() for c in frc.split(",") if c.strip()]
except Exception: # noqa: BLE001 - config absence/parse must never crash boot
pass
# allow_other_bots ← {PLATFORM}_ALLOW_BOTS in {"mentions","all"} (same gate as
# the gateway's own authz_mixin DISCORD_ALLOW_BOTS bypass).
allow_bots_env = os.environ.get(f"{platform.upper()}_ALLOW_BOTS", "").lower().strip()
allow_other_bots = allow_bots_env in {"mentions", "all"}
# Nothing CONFIGURED to declare ⇒ let the connector keep its default policy
# (mention-gated with agent-thread continuation — matches absence-of-row
# semantics on the connector side). NOTE the condition is "require_mention
# is unset", NOT "require_mention is falsy": the connector's default is now
# requireAddress=true, so an EXPLICIT `require_mention: false` is a
# non-default choice that MUST be declared or the connector would
# mention-gate an agent configured to free-respond.
if require_mention is None and not free_response and not allow_other_bots:
return None
require_address = bool(require_mention) if require_mention is not None else False
return {
"platform": platform,
"requireAddress": require_address,
"freeResponseScopes": free_response,
"allowOtherBots": allow_other_bots,
}
def _post_provision(
*,
provision_url: str,
access_token: str,
gateway_id: str,
platform: str,
bot_id: str,
gateway_endpoint: Optional[str],
route_keys: list[str],
instance_id: Optional[str] = None,
wake_url: Optional[str] = None,
display_name: Optional[str] = None,
timeout: float = 15.0,
) -> dict:
"""POST to the connector's ``/relay/provision`` and return the JSON body.
The connector validates ``access_token`` against NAS, derives the
authoritative tenant, mints the per-gateway secret + per-tenant delivery key,
upserts the tenant's route rows, and returns
``{secret, deliveryKey, tenant, gatewayId, routeKeys}``. Raises RuntimeError
with a user-facing message on any non-2xx / transport failure.
"""
import json
import urllib.error
import urllib.request
body: dict = {
"gatewayId": gateway_id,
"platform": platform,
"botId": bot_id,
"gatewayEndpoint": gateway_endpoint or "",
"routeKeys": route_keys,
}
# Only send instanceId when we actually have one — omitting it lets the
# connector store null (back-compat) rather than binding an empty string.
if instance_id:
body["instanceId"] = instance_id
# Same for the wake URL (Phase 5 §5.2): omit when absent so the connector
# stores null and simply can't wake this instance (buffering still works).
if wake_url:
body["wakeUrl"] = wake_url
# Same for the display name (Phase 1 parity, gg#171): omit when absent so
# the connector stores null and attribution falls back to the linked-owner
# identity.
if display_name:
body["displayName"] = display_name
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
provision_url,
data=data,
method="POST",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = (json.loads(exc.read().decode()) or {}).get("error", "")
except Exception:
pass
raise RuntimeError(
f"connector returned HTTP {exc.code}" + (f": {detail}" if detail else "")
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"could not reach connector: {exc.reason}") from exc
if not isinstance(payload, dict) or not payload.get("secret"):
raise RuntimeError("connector returned an unexpected response (no secret)")
return payload
def _resolve_relay_identity_token() -> str:
"""Resolve the caller-identity bearer token the connector introspects to a tenant.
Canonical resolver shared by the runtime self-provision path and the
``hermes gateway enroll`` CLI. Three modes, in precedence order:
1. **Generic OIDC client-credentials** (air-gapped / self-hosted-IdP, NO
Nous Portal): when ``gateway.idp.token_url`` (or
``GATEWAY_RELAY_IDP_TOKEN_URL``) is configured together with a client
id/secret, obtain a workload access token via the OAuth2
``client_credentials`` grant against the operator's own IdP (Entra;
Keycloak; Authentik in the sandbox). The connector's Seam-A OIDC
verifier reads a claim (default ``tid``) off it as the tenant.
1b. **Ambient token endpoint**: when ``token_url`` is configured with
NEITHER client_id nor client_secret, the URL is treated as a metadata-server-style
ambient credential endpoint (e.g. Domino's
``$DOMINO_API_PROXY/access-token``): a plain GET whose response body
IS the token — either a raw JWT string or a JSON envelope with an
``access_token`` field. No client registration involved; possession
of the (typically loopback) endpoint is the credential.
2. **Nous Portal** (default): ``resolve_nous_access_token()`` — existing
managed/hosted behaviour.
Raises on failure; callers decide whether that's fatal (enroll CLI) or a
graceful boot no-op (self-provision).
"""
token_url = os.environ.get("GATEWAY_RELAY_IDP_TOKEN_URL", "").strip()
client_id = os.environ.get("GATEWAY_RELAY_IDP_CLIENT_ID", "").strip()
client_secret = os.environ.get("GATEWAY_RELAY_IDP_CLIENT_SECRET", "").strip()
scope = os.environ.get("GATEWAY_RELAY_IDP_SCOPE", "").strip()
if not token_url:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
idp = ((_load_gateway_config().get("gateway") or {}).get("idp") or {})
token_url = str(idp.get("token_url", "") or "").strip()
client_id = client_id or str(idp.get("client_id", "") or "").strip()
client_secret = client_secret or str(idp.get("client_secret", "") or "").strip()
scope = scope or str(idp.get("scope", "") or "").strip()
except Exception: # noqa: BLE001 - config absence must not crash
token_url = token_url or ""
if not token_url:
# Mode 2 — Nous Portal (default, unchanged behaviour).
from hermes_cli.auth import resolve_nous_access_token
return resolve_nous_access_token()
import json
import urllib.error
import urllib.parse
import urllib.request
if not client_id and not client_secret:
# Mode 1b — ambient token endpoint (no client credentials configured).
# Plain GET; the body is the token, raw or JSON-enveloped.
req = urllib.request.Request(
token_url,
method="GET",
headers={"Accept": "application/json, text/plain"},
)
with urllib.request.urlopen(req, timeout=15.0) as resp:
body = resp.read().decode().strip()
token = ""
if body.startswith("{"):
try:
envelope_token = (json.loads(body) or {}).get("access_token")
except ValueError:
envelope_token = None
# Same contract as the client_credentials path below: the value
# must be a non-empty STRING. No shape gate here — a JSON envelope
# is a deliberate token response (and opaque tokens may use the
# standard-base64 alphabet the raw-body gate would reject).
if isinstance(envelope_token, str):
token = envelope_token.strip()
elif _AMBIENT_TOKEN_SHAPE.fullmatch(body):
token = body
if not token:
raise RuntimeError(
"no client_id/client_secret configured, so gateway.idp.token_url was "
"treated as an ambient token endpoint (GET), but the response body "
"was not a token. For the OAuth2 client_credentials grant, configure "
"client_id and client_secret alongside token_url."
)
return token
if not client_id or not client_secret:
# Exactly one credential configured: this is a mistyped client_credentials
# setup, not an ambient endpoint. Keep the loud error (never GET the IdP).
missing = "client_secret" if client_id else "client_id"
raise RuntimeError(
f"gateway.idp.token_url is configured with a partial client credential "
f"({missing} missing). Configure both client_id and client_secret for "
f"the OAuth2 client_credentials grant, or neither to treat token_url "
f"as an ambient token endpoint (plain GET returning the token)."
)
# Mode 1 — generic OAuth2 client_credentials grant.
form = {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
}
if scope:
form["scope"] = scope
req = urllib.request.Request(
token_url,
data=urllib.parse.urlencode(form).encode("utf-8"),
method="POST",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
)
with urllib.request.urlopen(req, timeout=15.0) as resp:
payload = json.loads(resp.read().decode())
access_token = (payload or {}).get("access_token")
if not isinstance(access_token, str) or not access_token.strip():
raise RuntimeError("IdP client_credentials response had no access_token")
return access_token.strip()
def self_provision_relay() -> bool:
"""Boot-time relay self-provision: mint relay creds in-process, no human, no disk.
Fires when relay is configured (``relay_url()`` set) and NO per-gateway secret
is already present, AND the agent can resolve its own Nous access token. In
that case the runtime resolves the agent's own Nous access token (the same
``resolve_nous_access_token()`` the enroll CLI / dashboard register use),
POSTs ``/relay/provision`` asserting its own endpoint + route keys, and sets
``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` / ``GATEWAY_RELAY_DELIVERY_KEY``
into ``os.environ`` so the subsequent ``register_relay_adapter()`` picks them
up. The creds live ONLY in process memory — never written to ``~/.hermes/.env``.
The trigger is deliberately NOT ``is_managed()``: that means
"package-manager/NixOS-managed" and is False on a NAS-hosted Fly agent (which
sets neither ``HERMES_MANAGED`` nor a ``.managed`` marker), so gating on it
blocked the exact hosted case this is for. The real signal is "you pointed me
at a connector and didn't pin a secret" — which is both NAS-independent and
self-guarding:
- A NAS-hosted agent: has ``GATEWAY_RELAY_URL``, no pinned secret, and a
bootstrapped NAS token -> self-provisions.
- A self-hosted operator who ran ``hermes gateway enroll``: has a PINNED
``GATEWAY_RELAY_SECRET`` -> skipped (the secret-present guard below).
- A self-hosted box with a relay URL but no NAS identity:
``resolve_nous_access_token()`` fails -> graceful no-op.
Stateless: process-env creds don't survive a restart, so a hosted container
re-provisions every boot; the connector's rotation window covers a still-
connected prior instance. An explicitly-pinned ``GATEWAY_RELAY_SECRET`` (env
or config) is RESPECTED — self-provision skips so an operator pin isn't
stomped.
Returns True if it provisioned, False otherwise. NEVER raises: a provision
failure logs and returns False so the gateway still boots (and
``register_relay_adapter`` will simply dial unauthenticated / be rejected,
rather than the whole gateway crashing).
"""
import logging
logger = logging.getLogger("gateway.relay")
dial_url = relay_url()
if not dial_url:
return False
# Respect an already-present (pinned/stamped) secret — don't stomp it. This
# is also what makes a self-hosted, enrolled gateway skip self-provision.
existing_id, existing_secret = relay_connection_auth()
if existing_id and existing_secret:
logger.info("relay self-provision skipped: GATEWAY_RELAY_SECRET already set")
return False
try:
access_token = _resolve_relay_identity_token()
except Exception as exc: # noqa: BLE001 - boot must survive a token failure
# No resolvable identity (e.g. a self-hosted box that hasn't enrolled and
# configured no IdP) -> nothing to provision with; skip quietly and boot.
logger.warning("relay self-provision skipped: could not resolve identity token (%s)", exc)
return False
identities = relay_platform_identities()
# gatewayId default mirrors the enroll CLI's hostname-based slug.
import socket
try:
host = socket.gethostname().strip()
except Exception: # noqa: BLE001
host = ""
gateway_id = os.environ.get("GATEWAY_RELAY_ID", "").strip() or f"gw-{host or 'hermes'}"
endpoint = relay_endpoint()
route_keys = relay_route_keys()
instance_id = relay_instance_id()
wake_url = relay_wake_url()
display_name = relay_display_name()
# Phase 1.5 (D-Q1.5c): provision EACH fronted platform under the SAME
# gatewayId + the SAME (platform-less) per-gateway secret. The connector's
# secret record is (gatewayId -> tenant) only; platform/botId live on the
# per-platform route rows (relayProvision.ts:124/148), so N provision POSTs
# with one gatewayId add N platforms' routes under one secret. The loop is
# PARTIAL-FAILURE-TOLERANT: a platform that fails to provision is logged and
# skipped (it just isn't fronted) — the others still come up. The FIRST
# successful provision sets the in-process creds; later platforms re-provision
# against the same gatewayId (idempotent on the secret, additive on routes).
provisioned: list[str] = []
result: dict = {}
for platform, bot_id in identities:
try:
result = _post_provision(
provision_url=_provision_url(dial_url),
access_token=access_token,
gateway_id=gateway_id,
platform=platform,
bot_id=bot_id,
gateway_endpoint=endpoint,
route_keys=route_keys,
instance_id=instance_id,
wake_url=wake_url,
display_name=display_name,
)
except RuntimeError as exc:
logger.warning(
"relay self-provision failed for platform=%s (%s); continuing with the rest",
platform,
exc,
)
continue
provisioned.append(platform)
# Set creds in-process on the FIRST success so register_relay_adapter()
# reads them from os.environ (the per-gateway secret authenticates the
# outbound WS upgrade). Subsequent platforms share the same gatewayId +
# secret (the connector returns the same record for the same gatewayId).
# Never logged.
if "GATEWAY_RELAY_SECRET" not in os.environ or not os.environ.get("GATEWAY_RELAY_SECRET"):
os.environ["GATEWAY_RELAY_ID"] = str(result.get("gatewayId") or gateway_id)
os.environ["GATEWAY_RELAY_SECRET"] = str(result.get("secret") or "")
os.environ["GATEWAY_RELAY_DELIVERY_KEY"] = str(result.get("deliveryKey") or "")
if not provisioned:
logger.warning(
"relay self-provision failed for ALL platforms (%s); gateway will boot without relay auth",
",".join(p for p, _ in identities),
)
return False
tenant = str(result.get("tenant") or "")
logger.info(
"relay self-provisioned (gateway_id=%s tenant=%s platforms=%s routes=%d inbound=%s instance=%s wake=%s)",
os.environ.get("GATEWAY_RELAY_ID", gateway_id),
tenant or "?",
",".join(provisioned),
len(route_keys),
"yes" if endpoint else "outbound-only",
instance_id or "unbound",
"yes" if wake_url else "none",
)
return True
def _post_policy(*, policy_url: str, token: str, policy: dict, timeout: float = 15.0) -> int:
"""POST the relevance policy to the connector's ``/relay/policy``; return the HTTP status.
Authenticated with the gateway's own per-gateway upgrade token (the SAME
bearer shape as the WS upgrade — ``make_upgrade_token``), so the connector
resolves ``{tenant, instanceId}`` from its stored secret record, never the
body. Raises RuntimeError on transport failure (the caller treats any
failure as non-fatal — relevance is an optimization, not a boot dependency).
"""
import json
import urllib.error
import urllib.request
data = json.dumps(policy).encode("utf-8")
req = urllib.request.Request(
policy_url,
data=data,
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return int(resp.status)
except urllib.error.HTTPError as exc:
return int(exc.code)
except urllib.error.URLError as exc:
raise RuntimeError(f"could not reach connector: {exc.reason}") from exc
def send_relay_policy() -> bool:
"""Declare this gateway's relevance policy to the connector (Phase 6 Unit ζ).
Runs at boot AFTER the per-gateway secret is resolved (self-provisioned or
pinned), projecting the agent's relevance config into the generic vocabulary
(``relay_relevance_policy``) and POSTing it to ``/relay/policy`` with the
gateway's own upgrade token. The connector stores it per-instance and the
relevance gate enforces it on delivery — so the SAME mention-gating /
free-response / allow-bots behavior the agent applies directly also governs
relay delivery, and excluded traffic never wakes a scaled-to-zero agent.
Self-healing: the agent is the source of truth and re-declares every boot
(mirrors the ``routeKeys`` upsert at provision). Idempotent — a full replace.
NEVER raises and NEVER blocks boot: relevance is an optimization layered on
the δ/ε authorization gate (which already protects isolation), so a failed
declaration just means the connector keeps the prior/quiet policy. Returns
True iff the connector accepted the policy (HTTP 200).
"""
import logging
logger = logging.getLogger("gateway.relay")
dial_url = relay_url()
if not dial_url:
return False
gateway_id, secret = relay_connection_auth()
if not gateway_id or not secret:
# No resolved per-gateway secret (unenrolled / provision failed) ⇒ we
# can't authenticate the policy POST; skip quietly (the WS upgrade would
# be unauthenticated too, so there's no instance to attach a policy to).
return False
# Phase 1.5: declare a policy PER fronted platform — the connector keys the
# relevance policy by (tenant, platform, instanceId), so each platform this
# gateway fronts gets its own row. Per-platform, partial-tolerant: a platform
# with nothing non-default to declare is skipped; a failed POST for one
# platform doesn't block the others. A single-platform gateway declares one
# policy exactly as before.
try:
from gateway.relay.auth import make_upgrade_token
token = make_upgrade_token(gateway_id, secret)
except Exception as exc: # noqa: BLE001 - boot must survive a token-build failure
logger.warning("relay policy declaration failed to build token (%s); connector keeps prior policy", exc)
return False
any_declared = False
for platform, _bot_id in relay_platform_identities():
policy = relay_relevance_policy(platform)
if policy is None:
# Nothing non-default to declare for this platform ⇒ the connector's
# quiet default already matches; don't write a redundant row.
continue
try:
status = _post_policy(policy_url=_policy_url(dial_url), token=token, policy=policy)
except Exception as exc: # noqa: BLE001 - boot must survive a policy-declare failure
logger.warning(
"relay policy declaration failed for platform=%s (%s); continuing", platform, exc
)
continue
if status == 200:
any_declared = True
logger.info(
"relay policy declared (platform=%s require_address=%s free_scopes=%d allow_bots=%s)",
policy.get("platform"),
policy.get("requireAddress"),
len(policy.get("freeResponseScopes") or []),
policy.get("allowOtherBots"),
)
else:
logger.warning(
"relay policy declaration for platform=%s returned HTTP %s; connector keeps prior/default policy",
platform,
status,
)
return any_declared
def register_relay_adapter(force: bool = False, url: Optional[str] = None) -> bool:
"""Register the generic ``relay`` platform via the platform registry.
Registers when a relay URL is configured (or ``force=True`` for tests, which
builds a transport-less adapter — the unit-test posture). Returns True if
registration happened. Additive: uses the same registry path as plugin
adapters, so no core dispatch changes are needed.
When a URL is present the factory builds a live ``WebSocketRelayTransport``;
the ``RelayAdapter`` negotiates the real ``CapabilityDescriptor`` at
``connect()`` time via ``transport.handshake()``.
"""
resolved_url = url if url is not None else relay_url()
if not (force or resolved_url):
return False
from gateway.platform_registry import PlatformEntry, platform_registry
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
platform, bot_id = relay_platform_identity()
def _factory(config):
# Placeholder descriptor; replaced by the negotiated one at connect time
# when a transport is present. With no URL (force/test) the adapter is
# transport-less and keeps the placeholder.
placeholder = CapabilityDescriptor(
contract_version=CONTRACT_VERSION,
platform=platform,
label="Relay",
max_message_length=4096,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=False,
markdown_dialect="plain",
len_unit="chars",
)
transport = None
if resolved_url:
from gateway.relay.ws_transport import WebSocketRelayTransport
gateway_id, upgrade_secret = relay_connection_auth()
transport = WebSocketRelayTransport(
resolved_url,
platform,
bot_id,
# Phase 1.5: the full SET of (platform, bot_id) this gateway fronts.
# The transport sends one hello per identity (the connector
# accumulates them) and resolves the per-frame egress botId from
# this set. A single-platform deploy passes a 1-element list, so
# behaviour is byte-identical to before.
identities=relay_platform_identities(),
gateway_id=gateway_id,
upgrade_secret=upgrade_secret,
# Phase 5 §5.3: re-dial + re-handshake after an unexpected socket
# close so a gateway that went idle/suspended re-establishes its
# relay socket — which triggers the connector's buffered-flip drain
# (the delivery-leg onResume) on the new handshake.
reconnect=True,
)
return RelayAdapter(config, placeholder, transport=transport)
platform_registry.register(
PlatformEntry(
name="relay",
label="Relay",
adapter_factory=_factory,
check_fn=lambda: True,
source="builtin",
emoji="\U0001f50c",
)
)
return True
File diff suppressed because it is too large. Load diff
+168
View File
@@ -0,0 +1,168 @@
"""Gateway-side relay authentication primitives. EXPERIMENTAL.
The connector⇄gateway channel is authenticated because a gateway may be
customer-managed and internet-exposed (see the connector repo
``docs/connector-gateway-auth-design.md``). This module is the **gateway half**
of two HMAC schemes whose wire bytes must match the connector's TypeScript
exactly:
1. **WS upgrade auth** (gateway → connector): the gateway presents
``Authorization: Bearer <token>`` on the ``/relay`` WebSocket upgrade, where
``token = make_upgrade_token(gateway_id, secret)``. Mirrors the connector's
``relayAuthToken.ts`` ``makeToken`` (``src/core/relayAuthToken.ts``):
``base64url(f"{payload}:{exp}:{sig}")`` with
``sig = HMAC_SHA256(f"{payload}:{exp}", secret).hexdigest()`` and
``payload == gateway_id``.
2. **Inbound delivery signature** (connector → gateway): the connector signs
each inbound POST with the per-tenant *delivery key*, carried as
``x-relay-timestamp`` + ``x-relay-signature`` headers; the gateway verifies
before accepting the event. Mirrors the connector's ``deliverySigning.ts``:
``sig = HMAC_SHA256(f"{ts}.{body_json}", key).hexdigest()`` over the EXACT
request body bytes, with a replay-window skew check.
Both schemes use a **multi-secret verify list** (primary first, then a secondary
during a rotation window), exactly like ``api/src/handlers/stats_oauth.ts`` — so
a secret rotation doesn't invalidate outstanding tokens.
EXPERIMENTAL: may change without a deprecation cycle until ≥2 Class-1 platforms
validate the relay contract.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import time
from typing import Optional, Sequence
# Header names the connector uses for inbound delivery signatures
# (connector ``src/core/deliverySigning.ts`` — DELIVERY_TS_HEADER / SIG_HEADER).
DELIVERY_TS_HEADER = "x-relay-timestamp"
DELIVERY_SIG_HEADER = "x-relay-signature"
# Default replay window for an inbound delivery signature (connector default).
_DEFAULT_MAX_SKEW_SECONDS = 300
# Default TTL for an upgrade token (connector ``makeUpgradeToken`` default).
_DEFAULT_UPGRADE_TTL_SECONDS = 300
def _hmac_hex(payload: str, secret: str) -> str:
"""HMAC-SHA256 hex digest of ``payload`` under ``secret`` (UTF-8)."""
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
def sign(payload: str, secret: str) -> str:
"""HMAC-SHA256 hex digest — the connector's ``sign`` (relayAuthToken.ts)."""
return _hmac_hex(payload, secret)
def verify_signature(payload: str, sig_hex: str, secrets: Sequence[str]) -> bool:
"""Constant-time check that ``sig_hex`` is a valid HMAC of ``payload`` under
ANY of ``secrets`` (rotation window). Length-mismatched candidates are
skipped without a timing leak. Mirrors ``verifySignature``.
"""
try:
sig_buf = bytes.fromhex(sig_hex)
except (ValueError, TypeError):
return False
if len(sig_buf) == 0:
return False
for secret in secrets:
if not secret:
continue
expected = bytes.fromhex(_hmac_hex(payload, secret))
if len(expected) != len(sig_buf):
continue
if hmac.compare_digest(sig_buf, expected):
return True
return False
def make_token(payload: str, secret: str, ttl_seconds: int = 0) -> str:
"""Build a signed, optionally-expiring token — the connector's ``makeToken``.
``base64url(f"{payload}:{exp}:{sig}")`` where ``exp`` is a unix-seconds
expiry (0 = never) and ``sig = HMAC_SHA256(f"{payload}:{exp}", secret)``.
base64url is unpadded to match Node's ``Buffer.toString("base64url")``.
"""
exp = int(time.time()) + ttl_seconds if ttl_seconds > 0 else 0
signed = f"{payload}:{exp}"
sig = _hmac_hex(signed, secret)
raw = f"{signed}:{sig}".encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def make_upgrade_token(
gateway_id: str, secret: str, ttl_seconds: int = _DEFAULT_UPGRADE_TTL_SECONDS
) -> str:
"""The WS-upgrade bearer token a gateway sends: ``payload = gateway_id``.
The connector peeks ``gateway_id`` (the payload head) to index its secret
verify list, then verifies the signature against that gateway's stored
secret(s). Mirrors the connector's ``makeUpgradeToken``.
"""
return make_token(gateway_id, secret, ttl_seconds)
def verify_token(token: str, secrets: Sequence[str]) -> Optional[str]:
"""Verify a token built by ``make_token``; return the payload or None.
Splits from the right so a payload may itself contain colons (mirrors the
connector's ``verifyToken``). Rejects an expired token and any signature
that doesn't match a secret in the verify list.
"""
try:
# base64url decode with padding restored.
padded = token + "=" * (-len(token) % 4)
decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
except (ValueError, TypeError):
return None
parts = decoded.split(":")
if len(parts) < 3:
return None
sig = parts[-1]
try:
exp = int(parts[-2])
except ValueError:
return None
payload = ":".join(parts[:-2])
if exp != 0 and int(time.time()) > exp:
return None
signed = f"{payload}:{exp}"
return payload if verify_signature(signed, sig, secrets) else None
def _delivery_payload(ts: int, body_json: str) -> str:
"""Signed material for an inbound delivery: ``f"{ts}.{body_json}"``."""
return f"{ts}.{body_json}"
def verify_delivery_signature(
body_json: str,
timestamp: Optional[str],
signature: Optional[str],
verify_keys: Sequence[str],
max_skew_seconds: int = _DEFAULT_MAX_SKEW_SECONDS,
*,
now: Optional[int] = None,
) -> bool:
"""Verify a connector→gateway inbound delivery signature.
``body_json`` MUST be the exact request body bytes decoded as UTF-8 — the
connector signs over the literal serialized body, so the gateway verifies
over the literal received body (no re-serialization). Checks the timestamp
is within ``max_skew_seconds`` of now and the HMAC matches any key in the
rotation verify list. Mirrors the connector's ``verifyDeliverySignature``.
"""
if not timestamp or not signature:
return False
try:
ts = int(timestamp)
except (ValueError, TypeError):
return False
current = now if now is not None else int(time.time())
if abs(current - ts) > max_skew_seconds:
return False
return verify_signature(_delivery_payload(ts, body_json), signature, verify_keys)
+150
View File
@@ -0,0 +1,150 @@
"""Gateway-declared slash-command manifest for the relay lane (Phase 4).
The native Discord adapter registers its slash commands directly on the
Discord command tree (`_register_slash_commands`,
plugins/platforms/discord/adapter.py) — it holds the bot token. Over the
relay the CONNECTOR holds the token, so the gateway DECLARES the same
command set on its `hello` frame (`command_manifest`) and the connector
reconciles Discord's global application-command registration against it
(gateway-gateway `DiscordCommandRegistrar`: GET → diff → bulk PUT,
idempotent, best-effort).
This module is that declaration: the single source of truth for what the
relay lane advertises. It MIRRORS the native tree — same names, same
descriptions — so a user moving between a native-Discord deployment and a
hosted/relay one sees the same command palette. Interactions come back over
the passthrough plane and are normalized by
RelayAdapter._discord_interaction_to_event into the same "/name args"
COMMAND events the dispatcher already routes, so declaring a command here
requires NO new handler — the dispatcher's existing slash surface is the
handler.
Wire shape (per entry): {name, description, options?} where options rows are
Discord option objects passed through verbatim. Names must satisfy
Discord's CHAT_INPUT rules ([a-z0-9_-]{1,32}); the connector drops invalid
entries (fail-open per entry, never the whole manifest).
"""
from __future__ import annotations
from typing import Any, Dict, List
# Discord option type 3 = STRING.
_STR = 3
def _opt(name: str, description: str, *, choices: List[str] | None = None) -> Dict[str, Any]:
row: Dict[str, Any] = {
"type": _STR,
"name": name,
"description": description,
"required": False,
}
if choices:
row["choices"] = [{"name": c, "value": c} for c in choices]
return row
def build_relay_command_manifest() -> List[Dict[str, Any]]:
"""The relay lane's Discord slash-command manifest (native-tree mirror)."""
return [
{"name": "new", "description": "Start a new conversation"},
{"name": "reset", "description": "Reset your Hermes session"},
{
"name": "model",
"description": "Show or change the model",
"options": [_opt("name", "Model name. Leave empty to see current.")],
},
{
"name": "reasoning",
"description": "Show/change reasoning effort, or toggle showing it",
"options": [
_opt(
"effort",
"Level, reset, or show/hide. Leave empty to see current.",
choices=[
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
"reset",
"show",
"hide",
],
)
],
},
{
"name": "personality",
"description": "Set a personality",
"options": [_opt("name", "Personality name. Leave empty to list.")],
},
{"name": "retry", "description": "Retry your last message"},
{"name": "undo", "description": "Remove the last exchange"},
{"name": "status", "description": "Show Hermes session status"},
{"name": "sethome", "description": "Set this chat as the home channel"},
{"name": "stop", "description": "Stop the running Hermes agent"},
{
"name": "steer",
"description": "Inject a message after the next tool call (no interrupt)",
"options": [_opt("text", "What to tell the agent")],
},
{"name": "compress", "description": "Compress conversation context"},
{
"name": "title",
"description": "Set or show the session title",
"options": [_opt("text", "New title. Leave empty to show.")],
},
{
"name": "resume",
"description": "Resume a previously-named session",
"options": [_opt("name", "Session title or id")],
},
{"name": "usage", "description": "Show token usage for this session"},
{"name": "help", "description": "Show available commands"},
{"name": "insights", "description": "Show usage insights and analytics"},
{"name": "reload-mcp", "description": "Reload MCP servers from config"},
{
"name": "reload-skills",
"description": "Re-scan skills for new or removed entries",
},
{"name": "voice", "description": "Toggle voice reply mode"},
{"name": "update", "description": "Update Hermes Agent to the latest version"},
{"name": "restart", "description": "Gracefully restart the Hermes gateway"},
{
"name": "approve",
"description": "Approve a pending dangerous command",
"options": [
_opt("scope", "Approval scope", choices=["once", "session", "always", "all"])
],
},
{
"name": "deny",
"description": "Deny a pending dangerous command",
"options": [_opt("reason", "Why (relayed to the agent)")],
},
{
"name": "thread",
"description": "Create a new thread and start a Hermes session in it",
"options": [_opt("name", "Thread name")],
},
{
"name": "queue",
"description": "Queue a prompt for the next turn (doesn't interrupt)",
"options": [_opt("text", "The prompt to queue")],
},
{
"name": "bg",
"description": "Run a prompt in a separate background session",
"options": [_opt("text", "The prompt to run")],
},
{
"name": "btw",
"description": "Ask a side question about the current conversation",
"options": [_opt("text", "The question to answer")],
},
]
+193
View File
@@ -0,0 +1,193 @@
"""CapabilityDescriptor — the relay handshake payload. EXPERIMENTAL.
The connector hands a ``CapabilityDescriptor`` to the gateway's ``RelayAdapter``
at handshake time; it tells the adapter which platform it is fronting and which
capabilities to advertise to the ``GatewayStreamConsumer`` (char limit,
draft-streaming, edit/threading support, markdown dialect, length unit). It is
the linchpin of the generalization: one gateway adapter serves Discord,
Telegram, Matrix, Signal, ... without per-platform branching.
EXPERIMENTAL: this schema MAY CHANGE without a deprecation cycle until at least
two real Class-1 platforms have validated it. Evolution during the experimental
phase is additive-only, gated by ``contract_version`` (see
docs/relay-connector-contract.md).
Field origins (most are a wire-serializable projection of ``PlatformEntry`` plus
the per-instance capability methods on ``BasePlatformAdapter``):
- ``max_message_length`` -> ``PlatformEntry.max_message_length`` / adapter
``MAX_MESSAGE_LENGTH`` attribute (read by stream_consumer).
- ``len_unit`` -> selects which ``message_len_fn`` the adapter installs
("chars" = builtin len; "utf16" = Telegram-style UTF-16 code-unit counting).
- ``supports_draft_streaming`` -> adapter ``supports_draft_streaming()`` probe.
- ``supports_edit`` -> whether edit-based streaming is possible (Discord/
Telegram yes; Signal/SMS no -> consumer degrades to one-message-per-segment).
- ``supports_threads`` -> ``create_handoff_thread`` capability flag.
- ``markdown_dialect`` -> presentation hint (e.g. "markdown_v2", "discord").
- ``emoji`` / ``platform_hint`` / ``pii_safe`` -> ``PlatformEntry`` fields of the
same name.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
# Bump additively (never reinterpret an existing field) during the experimental
# phase; a breaking change requires updating both repos in lockstep.
CONTRACT_VERSION = 1
@dataclass(frozen=True)
class CapabilityDescriptor:
"""Immutable capability descriptor negotiated at relay handshake.
Frozen so a descriptor cannot be mutated after handshake — the adapter
advertises a fixed capability profile for the life of the connection.
"""
contract_version: int
platform: str
label: str
max_message_length: int
supports_draft_streaming: bool
supports_edit: bool
supports_threads: bool
markdown_dialect: str
len_unit: str # "chars" | "utf16"
emoji: str = "\U0001f50c" # 🔌 default (matches PlatformEntry default)
platform_hint: str = ""
pii_safe: bool = False
# Whether the connector can supply surrounding channel/group CONTEXT for an
# addressed turn (Model A pull / Model B buffer, per platform). Optional +
# defaults False so an older connector that never sends it is treated as
# "no context" — additive within contract_version 1. from_json filters
# unknown keys, so a connector sending this to an older gateway is safe too.
supports_context: bool = False
# Whether the connector's platform can host a FLAT continuable cron
# surface (native Slack's ``cron_continuable_surface: in_channel``): the
# brief posts top-level in the channel/DM and a plain reply continues the
# job via the flat ``(platform, chat_id, None)`` session. The scheduler
# fails safe to thread mode when False (D6 gate), so an older connector
# that never sends this keeps today's thread behavior — additive within
# contract_version 1.
supports_inchannel_continuable: bool = False
# Whether the connector's platform sender can render block-level
# formatting from raw markdown (Slack: rich_text lists, Block Kit
# tables/markdown blocks). When True AND the operator enables the
# rich_blocks/markdown_blocks knobs, the gateway stamps ``format_hints``
# into outbound send/edit metadata; the connector renders blocks and
# keeps the plain text as fallback. Default False — old connectors never
# receive hints, old gateways never send them. Additive within
# contract_version 1.
supports_block_formatting: bool = False
# Op-level capability discovery (Phase 1 parity): the outbound op names the
# connector's sender for this platform actually implements (e.g.
# ["send", "edit", "typing", "follow_up", "get_chat_info"]). Empty tuple =
# the connector predates the field; callers MUST treat that as "legacy op
# set" (send/edit/typing/follow_up) rather than "nothing supported", so an
# old connector keeps working unchanged. Additive within contract_version 1.
# Stored as a tuple so the frozen dataclass stays hashable/immutable.
supported_ops: tuple = ()
# The op set every connector supported before ``supported_ops`` existed.
# Used as the assumed capability set when a legacy connector sends no list.
LEGACY_OPS = ("send", "edit", "typing", "follow_up")
def supports_op(self, op: str) -> bool:
"""Whether the connector advertises the outbound op ``op``.
Fail-open for legacy connectors: an empty ``supported_ops`` means the
connector predates op discovery, so assume the legacy op set (the four
ops every connector implemented before the field existed). A NEW op
(e.g. ``get_chat_info``) is therefore only True when explicitly
advertised — exactly the discovery semantics Phase 1 needs: the gateway
can probe capability without trying the op and parsing an error.
"""
if not self.supported_ops:
return op in self.LEGACY_OPS
return op in self.supported_ops
def to_json(self) -> str:
"""Serialize to a compact, stable JSON string for the handshake frame."""
return json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)
@classmethod
def from_json(cls, data: str) -> "CapabilityDescriptor":
"""Deserialize from a handshake JSON string.
Unknown keys are ignored (forward-compat: a newer connector may send
fields this gateway does not know yet); missing optional keys fall back
to dataclass defaults.
"""
raw = json.loads(data)
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
filtered = {k: v for k, v in raw.items() if k in known}
# Normalize the chunking bound at the trust boundary. A connector may
# advertise max_message_length 0 ("no limit"), and a buggy/hostile one
# may send 0 or a negative; either is a degenerate value that would flow
# straight into the adapter's MAX_MESSAGE_LENGTH and truncate_message().
# Map it to the documented 4096 default (docs/relay-connector-contract.md;
# mirrors from_platform_entry's `or 4096`) so from_json never yields a
# descriptor that can't chunk a real message.
if "max_message_length" in filtered:
try:
if int(filtered["max_message_length"]) <= 0:
filtered["max_message_length"] = 4096
except (TypeError, ValueError):
filtered["max_message_length"] = 4096
# Normalize supported_ops at the trust boundary: JSON carries a list;
# the frozen dataclass stores a tuple. Non-list/malformed values (or a
# list holding non-strings) degrade to () — the legacy-op-set fallback —
# rather than raising, matching the "malformed input never breaks the
# handshake" posture above.
if "supported_ops" in filtered:
raw_ops = filtered["supported_ops"]
if isinstance(raw_ops, (list, tuple)):
filtered["supported_ops"] = tuple(
str(op) for op in raw_ops if isinstance(op, str) and op
)
else:
filtered["supported_ops"] = ()
return cls(**filtered)
@classmethod
def from_platform_entry(
cls,
entry,
*,
len_unit: str = "chars",
supports_draft_streaming: bool = False,
supports_edit: bool = True,
supports_threads: bool = False,
markdown_dialect: str = "plain",
) -> "CapabilityDescriptor":
"""Project a ``gateway.platform_registry.PlatformEntry`` into a descriptor.
Demonstrates the descriptor is a *subset/projection* of what
``PlatformEntry`` already encodes, not a parallel concept: ``label``,
``max_message_length``, ``emoji``, ``platform_hint``, ``pii_safe`` and
the platform name come straight off the entry. The runtime capability
bits that ``PlatformEntry`` does NOT encode (length unit, draft/edit/
thread/markdown behavior) are supplied by the caller — in production
the connector fills these from the live adapter's capability methods.
``max_message_length`` of 0 on a ``PlatformEntry`` means "no limit";
we map that to the stream_consumer default of 4096 so the descriptor
always carries a concrete chunking bound.
"""
max_len = getattr(entry, "max_message_length", 0) or 4096
return cls(
contract_version=CONTRACT_VERSION,
platform=entry.name,
label=entry.label,
max_message_length=max_len,
supports_draft_streaming=supports_draft_streaming,
supports_edit=supports_edit,
supports_threads=supports_threads,
markdown_dialect=markdown_dialect,
len_unit=len_unit,
emoji=getattr(entry, "emoji", "\U0001f50c"),
platform_hint=getattr(entry, "platform_hint", ""),
pii_safe=getattr(entry, "pii_safe", False),
)
+214
View File
@@ -0,0 +1,214 @@
"""Relay media client — gateway↔connector media plane (Phase 2). EXPERIMENTAL.
The relay wire contract carries media BY REFERENCE, never by value: an inbound
event's ``media_urls`` name connector re-hosted attachments
(``{connector}/relay/media/{id}``), and an outbound ``send_media`` op names a
``source_url`` the connector resolves back to bytes. This module is the
gateway-side HTTP client for that plane:
- ``download(url)`` → GET a re-hosted attachment to a local temp file (the
agent's vision/file tools consume LOCAL paths, matching every native
adapter's inbound media behaviour).
- ``upload(path)`` → POST local file bytes to ``/relay/media``; returns the
``/relay/media/{id}`` reference for a subsequent ``send_media`` op. This is
how a locally-generated artifact (image_generate output, TTS voice note,
a document) crosses to the connector WITHOUT the gateway needing a public
URL.
Both requests present the SAME per-gateway signed bearer the WS upgrade uses
(``make_upgrade_token``, gateway/relay/auth.py — the channel authenticator; the
connector authenticates it with ``authenticateGatewayBearer`` on the mirrored
routes). Uploads are
per-gateway-owned on the connector (only this gateway can reference the id
back); downloads accept both this gateway's uploads and connector ingest
re-hosts.
Transport is stdlib ``urllib`` run in a thread executor (the same dependency
posture as ``_post_provision`` — the relay lane adds no HTTP client deps).
EXPERIMENTAL: may change without a deprecation cycle (docs/relay-connector-contract.md).
"""
from __future__ import annotations
import asyncio
import logging
import mimetypes
import os
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional
from gateway.relay.auth import make_upgrade_token
logger = logging.getLogger(__name__)
# Mirror the connector's MEDIA_MAX_BYTES (mediaStore.ts) so an oversized local
# artifact fails fast here instead of round-tripping to a connector 413.
MEDIA_MAX_BYTES = 25 * 1024 * 1024
_REQUEST_TIMEOUT_S = 30.0
def media_base_url(relay_dial_url: str) -> str:
"""Map the ``ws(s)://…/relay`` dial URL to the ``http(s)://…`` base.
Same host derivation as ``_provision_url`` (gateway/relay/__init__.py):
scheme ws→http / wss→https, trailing ``/relay`` stripped.
"""
raw = (relay_dial_url or "").strip().rstrip("/")
if raw.startswith("ws://"):
raw = "http://" + raw[len("ws://") :]
elif raw.startswith("wss://"):
raw = "https://" + raw[len("wss://") :]
if raw.endswith("/relay"):
raw = raw[: -len("/relay")]
return raw
# Discord's CDN (and other public hosts) reject urllib's default
# ``Python-urllib/x.y`` User-Agent with HTTP 403 — which silently killed EVERY
# Discord CDN pass-through download (voice notes, images, documents): the
# localizer kept the raw URL and downstream consumers then tried to open a URL
# as a file path. Always send a descriptive UA.
_MEDIA_USER_AGENT = "HermesAgent-Relay/1.0 (+https://github.com/NousResearch/hermes-agent)"
class RelayMediaClient:
"""Authenticated client for the connector's ``/relay/media`` routes."""
def __init__(
self,
base_url: str,
gateway_id: Optional[str],
secret: Optional[str],
) -> None:
self._base_url = base_url.rstrip("/")
self._gateway_id = gateway_id or ""
self._secret = secret or ""
@property
def enabled(self) -> bool:
"""True when the client can authenticate (per-gateway creds present)."""
return bool(self._base_url and self._gateway_id and self._secret)
def _bearer(self) -> str:
return make_upgrade_token(self._gateway_id, self._secret)
def is_relay_media_url(self, url: str) -> bool:
"""Is ``url`` a connector re-host reference (needs our bearer to GET)?"""
return "/relay/media/" in (url or "")
async def upload(
self,
file_path: str,
*,
mime: Optional[str] = None,
filename: Optional[str] = None,
) -> Optional[str]:
"""POST local file bytes to ``/relay/media``; return the reference URL.
Returns the ``{base}/relay/media/{id}`` reference for a ``send_media``
op's ``source_url``, or None on any failure (callers fall back to their
pre-media behaviour — media delivery is best-effort by design).
"""
if not self.enabled:
return None
path = Path(file_path)
try:
data = path.read_bytes()
except OSError:
logger.warning("relay media upload: cannot read %s", file_path)
return None
if not data or len(data) > MEDIA_MAX_BYTES:
logger.warning(
"relay media upload: %s size %d outside (0, %d]",
file_path,
len(data),
MEDIA_MAX_BYTES,
)
return None
content_type = (
mime
or mimetypes.guess_type(filename or path.name)[0]
or "application/octet-stream"
)
headers = {
"User-Agent": _MEDIA_USER_AGENT,
"Authorization": f"Bearer {self._bearer()}",
"Content-Type": content_type,
"X-Media-Filename": (filename or path.name)[:255],
}
url = f"{self._base_url}/relay/media"
def _post() -> Optional[str]:
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_S) as resp:
import json
body = json.loads(resp.read().decode("utf-8"))
media_id = body.get("id")
if not media_id:
return None
return f"{self._base_url}/relay/media/{media_id}"
except (urllib.error.URLError, ValueError, OSError) as exc:
logger.warning("relay media upload failed: %s", exc)
return None
return await asyncio.get_running_loop().run_in_executor(None, _post)
async def download(self, url: str, *, suggested_name: Optional[str] = None) -> Optional[str]:
"""GET a re-hosted attachment to a local temp file; return its path.
Presents the per-gateway bearer for connector re-host URLs; plain
public URLs (e.g. a Discord CDN pass-through) are fetched without it.
Returns None on any failure (the event then keeps the remote URL, and
downstream consumers that need a local file skip it — best-effort).
"""
if not url:
return None
needs_auth = self.is_relay_media_url(url)
if needs_auth and not self.enabled:
return None
headers = {"User-Agent": _MEDIA_USER_AGENT}
if needs_auth:
headers["Authorization"] = f"Bearer {self._bearer()}"
def _get() -> Optional[str]:
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_S) as resp:
length = int(resp.headers.get("Content-Length") or 0)
if length > MEDIA_MAX_BYTES:
logger.warning("relay media download too large: %s", url)
return None
data = resp.read(MEDIA_MAX_BYTES + 1)
if not data or len(data) > MEDIA_MAX_BYTES:
return None
# Extension: prefer the response's content-disposition /
# suggested name, fall back to the mime type, then .bin —
# vision/file tools sniff by extension.
name = suggested_name or ""
if not name:
cd = resp.headers.get("Content-Disposition") or ""
if "filename=" in cd:
name = cd.split("filename=", 1)[1].strip().strip('"')
ext = Path(name).suffix if name else ""
if not ext:
mime = (resp.headers.get("Content-Type") or "").split(";")[0]
ext = mimetypes.guess_extension(mime) or ".bin"
fd, tmp_path = tempfile.mkstemp(prefix="relay_media_", suffix=ext)
with os.fdopen(fd, "wb") as fh:
fh.write(data)
return tmp_path
except (urllib.error.URLError, ValueError, OSError) as exc:
logger.warning("relay media download failed for %s: %s", url, exc)
return None
return await asyncio.get_running_loop().run_in_executor(None, _get)
__all__ = ["RelayMediaClient", "media_base_url", "MEDIA_MAX_BYTES"]
+143
View File
@@ -0,0 +1,143 @@
"""Relay transport protocol — the gateway<->connector wire contract. EXPERIMENTAL.
The ``RelayAdapter`` (gateway side) delegates all wire I/O to a ``RelayTransport``.
The gateway dials OUT to the connector, so a production transport is a WebSocket
client; in tests it is an in-memory stub (``tests/gateway/relay/stub_connector.py``).
This module defines the protocol surface only — no concrete transport. The
contract has four concerns:
1. Lifecycle: ``connect`` / ``disconnect``.
2. Handshake: ``handshake`` returns the ``CapabilityDescriptor`` the connector
advertises for the platform this adapter fronts.
3. Inbound: ``set_inbound_handler`` registers a callback the transport invokes
with each normalized ``MessageEvent`` the connector delivers.
4. Outbound: ``send_outbound`` carries send/edit/typing actions back to the
connector; ``get_chat_info`` proxies a chat-info lookup; ``send_interrupt``
routes a mid-turn /stop down the socket that owns the session_key.
EXPERIMENTAL: may change without a deprecation cycle until >=2 Class-1 platforms
validate it. See docs/relay-connector-contract.md.
"""
from __future__ import annotations
from typing import Any, Awaitable, Callable, Dict, Optional, Protocol, runtime_checkable
from gateway.platforms.base import MessageEvent
from gateway.relay.descriptor import CapabilityDescriptor
# Callback the transport invokes for each inbound normalized event.
InboundHandler = Callable[[MessageEvent], Awaitable[None]]
# Callback the transport invokes for each forwarded passthrough request (§5.1).
# The first arg is a PassthroughForward (gateway/relay/ws_transport.py) — typed
# as Any here to keep this protocol module free of a concrete-transport import
# (ws_transport imports FROM this module). The second is an optional bufferId
# (Phase 5 §5.3 buffered flip) the handler acks after durable handoff.
PassthroughHandler = Callable[[Any, Optional[str]], Awaitable[None]]
@runtime_checkable
class RelayTransport(Protocol):
"""Full gateway<->connector transport contract."""
async def connect(self) -> bool:
"""Open the connection to the connector; return True on success."""
...
async def disconnect(self) -> None:
"""Close the connection."""
...
async def handshake(self) -> CapabilityDescriptor:
"""Return the capability descriptor the connector advertises."""
...
def set_inbound_handler(self, handler: InboundHandler) -> None:
"""Register the callback invoked with each inbound MessageEvent."""
...
def set_passthrough_handler(self, handler: "PassthroughHandler") -> None:
"""Register the callback invoked with each forwarded passthrough request.
Phase 5 §5.1: the passthrough plane (Discord interactions, Twilio, …)
answers the provider's edge ACK at the connector, then forwards the real
request to the gateway over this same outbound socket (a hosted gateway
has no public inbound port). The transport invokes ``handler(forward,
buffer_id)`` for each ``passthrough_forward`` frame. Optional on a
transport (an in-memory stub may not implement it).
"""
...
async def send_outbound(
self, action: Dict[str, Any], *, platform: Optional[str] = None
) -> Dict[str, Any]:
"""Carry an outbound action (send/edit/typing) to the connector.
Returns a result dict; for ``op == "send"`` it carries
``success`` and optionally ``message_id`` / ``error``.
``platform`` (Phase 1.5) tags WHICH fronted platform this reply targets,
carried on the OutboundFrame envelope so a gateway fronting N platforms
egresses each reply through the right sender (the transport resolves the
matching advertised botId). Omitted ⇒ the connector falls back to the
session's default platform (single-platform deploys unchanged).
"""
...
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Proxy a chat-info lookup to the connector."""
...
async def send_interrupt(self, session_key: str, reason: Optional[str] = None) -> None:
"""Route a mid-turn /stop to the connector for ``session_key``.
The connector forwards it down the socket owned by the gateway
instance running that session (the /stop routing invariant). On the
gateway side this is the OUTBOUND direction; the actual task
cancellation happens when the connector echoes an interrupt inbound
(handled in Task 1.4).
"""
...
async def go_idle(self, timeout_s: float = 10.0) -> bool:
"""Ask the connector to flip this instance to buffered-only (Phase 5 §5.3).
Sends ``going_idle`` and awaits the connector's ``going_idle_ack`` — the
connector-authoritative confirmation that live delivery stopped and inbound
now buffers durably for replay on reconnect (Q-5.3c). Returns True on ack,
False on timeout / not-connected (the caller proceeds to close regardless;
without §5.3 wiring there is simply no buffering). Optional on a transport
(an in-memory stub may not implement it). Emitted as part of the gateway's
EXISTING drain transition — not a new idle path.
"""
...
async def send_follow_up(
self, action: Dict[str, Any], *, platform: Optional[str] = None
) -> Dict[str, Any]:
"""Act on a shared-identity capability bound to a session (A2 outbound).
Some platforms hand the connector a credential that acts on the SHARED
bot identity (e.g. a Discord interaction follow-up token, valid ~15min).
Under A2 that credential NEVER reaches the gateway — the connector
stripped it at the edge and bound it in its capability vault keyed by
the session. To use it, the gateway issues a SEMANTIC action against the
session it is already in; it never names or holds a token.
The action dict carries:
``op`` == ``"follow_up"``
``session_key`` the session whose bound capability to wield
``kind`` the capability kind (e.g. ``"discord.interaction_token"``)
``content`` the message content to send via that capability
``metadata?`` optional extras
The connector resolves the real capability (``resolveOutboundCapability``
on its side), enforces the tenant match (tenant B can never wield tenant
A's capability), and egresses. Returns ``{success, message_id?, error?}``;
``success`` is False when the capability is absent/expired or the tenant
doesn't match — the gateway then has nothing to retry with (by design: a
leaked gateway holds zero capability material).
"""
...
File diff suppressed because it is too large. Load diff
+147
View File
@@ -0,0 +1,147 @@
"""Gateway response filtering helpers.
These helpers operate at the gateway boundary: they decide whether a completed
agent turn should be delivered to the chat, not what should be persisted in the
conversation history.
"""
from __future__ import annotations
import unicodedata
from typing import Any
# Canonical model-emitted control token for intentional silence.
SILENT_REPLY_TOKEN = "NO_REPLY"
# Exact whole-response markers that mean "the agent intentionally chose not to
# reply". Keep this list small and explicit; arbitrary empty output remains an
# error/empty-response path, not silence.
LIVE_GATEWAY_SILENT_MARKERS = frozenset({
"[SILENT]",
"SILENT",
"NO_REPLY",
"NO REPLY",
})
def _canonical_silence_candidate(text: str) -> str:
return " ".join(text.strip().upper().split())
def _strip_edge_silence_punctuation(text: str) -> str:
"""Strip stray edge punctuation without erasing marker structure.
Models sometimes emit ``.NO_REPLY`` or ``*NO_REPLY*`` instead of the exact
marker. Keep square brackets structural so malformed ``[SILENT`` does not
become ``SILENT``.
"""
start = 0
end = len(text)
while start < end and text[start] not in "[]" and unicodedata.category(text[start]).startswith("P"):
start += 1
while end > start and text[end - 1] not in "[]" and unicodedata.category(text[end - 1]).startswith("P"):
end -= 1
return text[start:end].strip()
def _canonical_silence_candidates(text: str) -> tuple[str, ...]:
exact = _canonical_silence_candidate(text)
stripped = _strip_edge_silence_punctuation(text.strip())
if stripped == text.strip():
return (exact,)
fallback = _canonical_silence_candidate(stripped)
return (exact, fallback)
def is_intentional_silence_response(response: Any) -> bool:
"""Return True only when ``response`` is exactly a silence marker.
Substantive prose that merely mentions ``NO_REPLY`` or ``[SILENT]`` must be
delivered normally. A blank response is also not silence; blank output is
handled by the empty-response failure path.
"""
if not isinstance(response, str):
return False
stripped = response.strip()
if not stripped:
return False
if len(stripped) > 64:
return False
return any(candidate in LIVE_GATEWAY_SILENT_MARKERS for candidate in _canonical_silence_candidates(stripped))
def is_autonomous_silence_response(response: Any) -> bool:
"""Loose silence matcher for autonomous lanes (cron, webhook).
Autonomous lanes instruct the agent to emit ``[SILENT]`` when a tick
produced nothing worth a human's attention, and models reliably bracket
the marker with a short note explaining why they stayed quiet. Unlike
:func:`is_intentional_silence_response` (the interactive-chat rule, which
demands the response be EXACTLY a marker), this suppresses when a marker
is the whole response, sits on its own first or last line, or the
bracketed sentinel opens the response (the documented
``[SILENT] No changes detected`` pattern). A token buried mid-sentence
in a genuine report is still delivered.
Shares :data:`LIVE_GATEWAY_SILENT_MARKERS` so the interactive and
autonomous marker sets can never drift apart.
"""
if not isinstance(response, str):
return False
stripped = response.strip()
if not stripped:
return False
def _is_token(line: str) -> bool:
return _canonical_silence_candidate(line) in LIVE_GATEWAY_SILENT_MARKERS
# Whole response is exactly a token.
if _is_token(stripped):
return True
# Marker on its own first or last line (leading/trailing note on a
# separate line — e.g. "2 deals filtered\n\n[SILENT]").
lines = [ln for ln in stripped.splitlines() if ln.strip()]
if lines and (_is_token(lines[0]) or _is_token(lines[-1])):
return True
# Bracketed sentinel used as a same-line prefix — the documented pattern
# "[SILENT] No changes detected". Restricted to the bracketed form so a
# bare word like "Silent retry succeeded" is NOT swallowed.
if stripped.upper().startswith("[SILENT]"):
return True
return False
def is_intentional_silence_agent_result(agent_result: dict | None, response: Any) -> bool:
"""Silence markers suppress delivery only for successful agent turns."""
if not isinstance(agent_result, dict):
return False
if agent_result.get("failed"):
return False
return is_intentional_silence_response(response)
def is_partial_silence_marker(text: Any) -> bool:
"""Return True while ``text`` could still resolve to a silence marker.
The streaming path accumulates the reply delta-by-delta and must decide,
before the whole response is known, whether to show what it has so far.
A buffer whose canonical form is a non-empty *prefix* of a silence marker
(e.g. ``"NO"`` on the way to ``"NO_REPLY"``, or an exact marker that has
not yet been terminated by stream-end) is held back so a raw marker is
never edited onto the screen and then belatedly retracted.
Anything that has already diverged from every marker (ordinary prose) —
and anything longer than the marker cap — returns False so normal
streaming resumes immediately. This is the streaming counterpart to
:func:`is_intentional_silence_response`, sharing the same marker set and
canonicalization so the two never drift.
"""
if not isinstance(text, str):
return False
stripped = text.strip()
if not stripped or len(stripped) > 64:
return False
for candidate in _canonical_silence_candidates(stripped):
if candidate and any(marker.startswith(candidate) for marker in LIVE_GATEWAY_SILENT_MARKERS):
return True
return False
+278
View File
@@ -0,0 +1,278 @@
"""Shared gateway restart constants and supervisor detection helpers."""
import math
import os
from collections.abc import Mapping
from hermes_cli.config import DEFAULT_CONFIG
# EX_TEMPFAIL from sysexits.h — used to ask the service manager to restart
# the gateway after a graceful drain/reload path completes.
GATEWAY_SERVICE_RESTART_EXIT_CODE = 75
# EX_CONFIG from sysexits.h — fatal configuration error (e.g. token
# collision, no messaging platforms). The s6 finish script translates
# this into exit 125 (permanent failure) so the supervisor stops
# restarting the gateway. See #51228.
GATEWAY_FATAL_CONFIG_EXIT_CODE = 78
def is_global_startup_conflict(error_code: str | None) -> bool:
"""Return True when an adapter's fatal error is a single-writer ownership conflict.
``BasePlatformAdapter._acquire_platform_lock`` emits ``{scope}_lock``
with ``retryable=True`` on purpose: a *mid-run* reconnect must be able to
recover once the live holder exits or a stale record is cleared (#54167).
At startup, though, a live foreign holder is a configuration conflict —
two gateways cannot poll one bot token — so the startup router must not
treat that flag as "transient blip, retry-queue forever". This matches by
error CODE only (the ``{scope}_lock`` / ``lock_conflict`` families every
adapter emits for scoped-lock and identity conflicts), never by message
text.
"""
code = (error_code or "").strip().lower()
if not code:
return False
return code == "lock_conflict" or code.endswith("_lock")
# Set by ``hermes gateway run --external-supervisor``. Unlike systemd's
# INVOCATION_ID and launchd's XPC_SERVICE_NAME, this survives wrappers that
# intentionally replace the child environment (for example ``sudo env -i``).
EXTERNAL_GATEWAY_SUPERVISOR_ENV = "HERMES_GATEWAY_EXTERNAL_SUPERVISOR"
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT = float(
DEFAULT_CONFIG["agent"]["restart_drain_timeout"]
)
DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT = float(
DEFAULT_CONFIG["gateway"]["signal_interrupt_grace_timeout"]
)
DEFAULT_GATEWAY_POST_INTERRUPT_GRACE_TIMEOUT = 5.0
# In-band restart (``/restart``, SIGUSR1, self-restart from a child CLI)
# waits for active turns to finish *before* ``stop()`` begins. Distinct
# from ``restart_drain_timeout``, which is the force-interrupt budget
# once ``stop()`` is running (and must stay short under systemd
# TimeoutStopSec). See #77184.
DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT = float(
DEFAULT_CONFIG["agent"]["restart_after_turn_timeout"]
)
# Cron-only floor under the ``stop()`` drain. ``restart_drain_timeout``
# defaults to 0 because interrupting a *chat* turn is cheap and recoverable:
# the user is told the gateway is restarting and the session is pre-marked
# resume_pending. An interrupted *cron* run has neither property — nobody is
# waiting on it, it lands in jobs.json as a permanent failure, and a recurring
# job just waits for its next schedule — so a zero-second drain silently
# destroys work. See #82161.
DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT = float(
DEFAULT_CONFIG["agent"]["cron_drain_timeout"]
)
# Seconds of the shutdown watchdog leash held back for the work that still has
# to happen after the drain returns: interrupt agents, kill tool subprocesses,
# mark in-flight jobs interrupted, disconnect adapters. Waiting for cron past
# that point trades a job that is killed *and recorded* for one that is
# SIGKILLed mid-write and stays wedged at ``last_status=running`` forever.
CRON_DRAIN_CLEANUP_RESERVE_S = 10.0
# systemd TimeoutStopSec headroom after the stop-path drain budget, and the
# floor used when that budget is still the default immediate (0s) chat drain.
# Keep these in lockstep with generate_systemd_unit() / #94759.
SYSTEMD_STOP_HEADROOM_S = 30.0
SYSTEMD_TIMEOUT_STOP_SEC_FLOOR = 60.0
def is_gateway_supervisor_process(
environ: Mapping[str, str] | None = None,
) -> bool:
"""Return whether this gateway process is owned by a supervisor."""
env = os.environ if environ is None else environ
if env.get("INVOCATION_ID"):
return True
if env.get("HERMES_S6_SUPERVISED_CHILD"):
return True
xpc_service = env.get("XPC_SERVICE_NAME", "")
if xpc_service and xpc_service != "0":
return True
return str(env.get(EXTERNAL_GATEWAY_SUPERVISOR_ENV, "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
def is_container_restart_context() -> bool:
"""Return whether the gateway is running inside a container for restart
routing purposes (Docker/Podman ⇒ the detached setsid path dies with the
cgroup; exit-75 service restart is the only viable path).
Extracted from the inline probe in the /restart handler so tests can mock
container detection hermetically — a real ``/.dockerenv`` on a
containerized CI runner otherwise flips the routing under the test.
"""
return os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
def parse_restart_drain_timeout(raw: object) -> float:
"""Parse a configured drain timeout, falling back to the shared default."""
try:
value = float(raw) if str(raw or "").strip() else DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
except (TypeError, ValueError):
return DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
return max(0.0, value)
def parse_restart_after_turn_timeout(raw: object) -> float:
"""Parse the after-turn wait cap for in-band restart, falling back to default.
``0`` is a deliberate disable (legacy immediate drain) and must not fall
through to the default — unlike empty/missing input.
"""
if raw is None:
return DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT
if isinstance(raw, str) and not raw.strip():
return DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT
try:
value = float(raw)
except (TypeError, ValueError):
return DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT
return max(0.0, value)
def parse_cron_drain_timeout(raw: object) -> float:
"""Parse the cron-only drain floor, falling back to the shared default.
``0`` is a deliberate opt-out — cron work is then interrupted on the same
budget as chat work, the pre-#82161 behaviour — and must not fall through
to the default, unlike empty/missing input.
"""
if raw is None:
return DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT
if isinstance(raw, str) and not raw.strip():
return DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT
try:
value = float(raw)
except (TypeError, ValueError):
return DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT
return max(0.0, value)
def resolve_cron_drain_budget(
drain_timeout: float,
cron_drain_timeout: float,
*,
watchdog_delay: float,
elapsed: float = 0.0,
cleanup_reserve_s: float = CRON_DRAIN_CLEANUP_RESERVE_S,
) -> float:
"""Seconds the shutdown drain may spend waiting on in-flight cron work.
The configured floor is clamped to what this process can actually honour.
The shutdown watchdog hard-exits at ``watchdog_delay`` and the service
manager's ``TimeoutStopSec`` is sized from the full stop budget (drain
vs cron floor + cleanup reserve, plus headroom — see
``resolve_systemd_timeout_stop_sec``), so waiting past that leash
(minus ``cleanup_reserve_s`` for the teardown that follows the drain)
would swap a cleanly-interrupted job for a SIGKILL that leaves it
wedged mid-run — strictly worse than the bug being fixed.
Never returns less than ``drain_timeout``: the cron floor only ever
extends the wait, so an operator who deliberately configured a long
``restart_drain_timeout`` keeps it.
"""
def _seconds(value: object, fallback: float = 0.0) -> float:
try:
return max(float(value), 0.0) # type: ignore[arg-type]
except (TypeError, ValueError):
return fallback
drain = _seconds(drain_timeout)
floor = _seconds(cron_drain_timeout)
if floor <= 0.0:
return drain
ceiling = (
_seconds(watchdog_delay)
- _seconds(elapsed)
- _seconds(cleanup_reserve_s, CRON_DRAIN_CLEANUP_RESERVE_S)
)
return max(drain, min(floor, ceiling))
def resolve_systemd_timeout_stop_sec(
drain_timeout: float,
cron_drain_timeout: float = DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT,
*,
cleanup_reserve_s: float = CRON_DRAIN_CLEANUP_RESERVE_S,
headroom_s: float = SYSTEMD_STOP_HEADROOM_S,
floor_s: float = SYSTEMD_TIMEOUT_STOP_SEC_FLOOR,
) -> int:
"""Seconds systemd ``TimeoutStopSec`` must cover the full stop budget.
``restart_drain_timeout`` is only the chat-turn interrupt budget (default
0). The stop path may wait longer for in-flight cron work —
``cron_drain_timeout`` plus ``cleanup_reserve_s`` — before it even starts
interrupting. Sizing the unit from drain alone lets systemd SIGKILL an
in-budget drain (#94759).
A zero ``cron_drain_timeout`` is a deliberate opt-out and does not extend
the budget. Non-numeric inputs degrade to 0 rather than raising.
"""
def _seconds(value: object) -> float:
try:
return max(float(value), 0.0) # type: ignore[arg-type]
except (TypeError, ValueError):
return 0.0
drain = _seconds(drain_timeout)
cron = _seconds(cron_drain_timeout)
reserve = _seconds(cleanup_reserve_s)
headroom = _seconds(headroom_s)
floor = _seconds(floor_s)
cron_budget = (cron + reserve) if cron > 0.0 else 0.0
stop_budget = max(drain, cron_budget)
return int(max(floor, stop_budget + headroom))
def resolve_restart_exit_wait_budget(
drain_timeout: float,
after_turn_timeout: float,
*,
headroom: float = 15.0,
) -> float:
"""Seconds a CLI should wait for the gateway PID to exit after SIGUSR1.
In-band restart may defer ``stop()`` until active turns finish
(``after_turn_timeout``) and then spend up to ``drain_timeout`` inside
``stop()``. Callers that fall back to a hard kill on wait expiry must
cover both phases or they reintroduce #77184.
"""
try:
drain = max(float(drain_timeout), 0.0)
except (TypeError, ValueError):
drain = 0.0
try:
after_turn = max(float(after_turn_timeout), 0.0)
except (TypeError, ValueError):
after_turn = 0.0
try:
margin = max(float(headroom), 0.0)
except (TypeError, ValueError):
margin = 0.0
return drain + after_turn + margin
def parse_signal_interrupt_grace_timeout(raw: object) -> float:
"""Parse the unexpected-signal post-interrupt grace timeout."""
try:
if raw is None or (isinstance(raw, str) and not raw.strip()):
value = DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT
else:
value = float(raw)
except (TypeError, ValueError):
return DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT
if not math.isfinite(value):
return DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT
return max(0.0, value)
+214
View File
@@ -0,0 +1,214 @@
"""Auto-resume restart-loop breaker (#30719, defense-3).
Defenses 1 and 2 (the ``_HERMES_GATEWAY`` guard on ``hermes gateway
stop|restart`` + ``terminal_tool``, and the cron-creation lifecycle
filter) stop the agent from scheduling its own restart via the cron and
CLI paths. They do NOT cover every SIGTERM source: an agent running a
raw ``terminal("launchctl kickstart -k gui/<uid>/ai.hermes.gateway")``,
an external monitor with a bad trigger, or any other repeated crash can
still drive the supervisor (launchd ``KeepAlive`` / systemd ``Restart=``)
into a tight respawn loop. On each boot the gateway auto-resumes the
restart-interrupted session, whose next turn re-runs the offending
logic — SIGTERM every ~10 seconds until manually broken.
This module is the last-resort circuit breaker: it records a timestamp
each time the gateway boots with restart-interrupted sessions pending,
keeps the current chain of such boots persisted across processes (each
boot is a fresh process, so in-memory state is useless), and reports the
loop as "tripped" once too many of them chain together. Boots chain
while consecutive gaps stay within ``max_gap_seconds``, so the breaker
sees slow crash cycles (a wedged event loop killed by the liveness
watchdog every ~150s, #81642) exactly as well as the fast ~10s respawn
loop it was originally written for.
When tripped, the caller SKIPS auto-resume for that boot — the gateway
still starts and serves real inbound messages, it just stops replaying
the session that keeps killing it, which breaks the cycle and puts a
human back in the loop.
State lives in ``<HERMES_HOME>/gateway/restart_loop.json`` so it is
profile-scoped and survives process death. It is intentionally tiny and
best-effort: any read/write failure fails OPEN (no false trip) because a
broken breaker must never wedge a healthy gateway.
"""
from __future__ import annotations
import json
import logging
import time
from typing import List, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger("gateway.run")
# Defaults chosen so a legitimate operator restart (or two) never trips the
# breaker, but the documented ~10s respawn loop does within a few cycles.
DEFAULT_MAX_RESTARTS = 3
DEFAULT_WINDOW_SECONDS = 60
# Longest gap between two consecutive restart-interrupted boots that still
# counts them as the SAME loop (#81642). A fixed ``window_seconds`` prune can
# only see crash cycles faster than the window: a loop whose period exceeds it
# drops its own history on every boot, so the counter never leaves 1 and the
# breaker never trips no matter how long the loop runs. The reported cycle was
# ~150s (wedged event loop -> ~90s liveness watchdog hard-exit -> respawn ->
# auto-resume replays the same session), i.e. structurally invisible to the 60s
# window. Chaining on the inter-boot GAP instead makes the breaker period-
# agnostic: any repeating cycle trips once ``max_restarts`` links accumulate,
# and a single boot followed by real quiet resets the chain.
DEFAULT_MAX_GAP_SECONDS = 300
# Cap the persisted chain so a long-running loop cannot grow the state file
# without bound. Only the newest ``max_restarts`` entries can change a
# verdict; the rest are kept for forensics.
_MAX_STORED_BOOTS = 50
def _state_path():
return get_hermes_home() / "gateway" / "restart_loop.json"
def _load_boots() -> List[float]:
try:
raw = _state_path().read_text(encoding="utf-8")
data = json.loads(raw)
boots = data.get("boots", [])
return [float(t) for t in boots if isinstance(t, (int, float))]
except (OSError, ValueError, TypeError):
return []
def _save_boots(boots: List[float]) -> None:
try:
path = _state_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"boots": boots}), encoding="utf-8")
except OSError:
pass
def _chain_gap(window_seconds: int, max_gap_seconds: int) -> float:
"""Effective inter-boot gap that still links two boots into one loop.
Floored by ``window_seconds`` so an operator who widens the window never
ends up with a breaker that is *less* sensitive than they asked for.
"""
return float(max(1, window_seconds, max_gap_seconds))
def _chain_ending_at(boots: List[float], ts: float, gap: float) -> List[float]:
"""Return the unbroken chain of boots leading up to ``ts``.
Walks backwards from ``ts`` and keeps boots while each successive gap stays
within ``gap``. The first gap that exceeds it ends the chain: everything
older belongs to a previous, already-resolved episode. A chain broken at
the head (nothing recent enough) yields an empty list, which is how a
healthy gateway forgets an old loop.
"""
chain: List[float] = []
prev = ts
for t in sorted(boots, reverse=True):
if t > ts:
# Clock moved backwards (NTP step, restored state file). Treat the
# future entry as adjacent rather than dropping the whole chain.
chain.append(t)
continue
if prev - t > gap:
break
chain.append(t)
prev = t
chain.reverse()
return chain
def record_restart_interrupted_boot(
window_seconds: int = DEFAULT_WINDOW_SECONDS,
*,
now: Optional[float] = None,
max_gap_seconds: int = DEFAULT_MAX_GAP_SECONDS,
) -> List[float]:
"""Record that the gateway just booted with restart-interrupted sessions.
Drops boots that belong to an earlier, already-broken chain (any gap wider
than ``max_gap_seconds``) and appends the current time. Returns the
pruned+appended list (most recent last). Best-effort — a persistence
failure returns the in-memory list without raising.
"""
ts = time.time() if now is None else now
gap = _chain_gap(window_seconds, max_gap_seconds)
boots = _chain_ending_at(_load_boots(), ts, gap)
boots.append(ts)
_save_boots(boots[-_MAX_STORED_BOOTS:])
return boots
def is_restart_loop_tripped(
max_restarts: int = DEFAULT_MAX_RESTARTS,
window_seconds: int = DEFAULT_WINDOW_SECONDS,
*,
now: Optional[float] = None,
max_gap_seconds: int = DEFAULT_MAX_GAP_SECONDS,
) -> bool:
"""Return True if the gateway has restarted ``>= max_restarts`` times with
restart-interrupted sessions in one unbroken chain ending at ``now``.
Reads the persisted boot log written by
``record_restart_interrupted_boot`` and counts the boots that still chain
together (consecutive gaps within ``max_gap_seconds``), so the verdict does
not depend on how fast the crash cycle happens to be.
Fails OPEN (returns False) on any error — a broken breaker must never
wedge a healthy gateway.
"""
if max_restarts <= 0:
return False
ts = time.time() if now is None else now
gap = _chain_gap(window_seconds, max_gap_seconds)
try:
recent = _chain_ending_at(_load_boots(), ts, gap)
except Exception: # pragma: no cover — _load_boots already guards
return False
return len(recent) >= max_restarts
def clear() -> None:
"""Remove the persisted boot log (used on clean shutdown / by tests)."""
try:
_state_path().unlink(missing_ok=True)
except OSError:
pass
def check_and_record(
max_restarts: int = DEFAULT_MAX_RESTARTS,
window_seconds: int = DEFAULT_WINDOW_SECONDS,
*,
now: Optional[float] = None,
max_gap_seconds: int = DEFAULT_MAX_GAP_SECONDS,
) -> bool:
"""Record this restart-interrupted boot and report whether the loop is now
tripped.
This is the single entry point the gateway calls: it appends the current
boot, then checks whether the (now-updated) chain has reached the
threshold. Returns True when auto-resume should be SKIPPED to break the
loop.
"""
boots = record_restart_interrupted_boot(
window_seconds, now=now, max_gap_seconds=max_gap_seconds
)
tripped = len(boots) >= max_restarts if max_restarts > 0 else False
if tripped:
logger.warning(
"Restart-loop breaker TRIPPED: %d chained restart-interrupted "
"gateway boots (no gap wider than %ds; threshold %d). Skipping "
"auto-resume to break a suspected SIGTERM-respawn loop (#30719, "
"#81642). Restart-interrupted sessions stay resume-pending and "
"will continue on the next real user message. If this is a false "
"positive, delete %s.",
len(boots),
int(_chain_gap(window_seconds, max_gap_seconds)),
max_restarts,
_state_path(),
)
return tripped
+83
View File
@@ -0,0 +1,83 @@
"""Local index of text we've sent via ``sendRichMessage`` (Bot API 10.1).
Telegram does NOT echo a rich message's content back in ``reply_to_message``
when a user replies to it (verified: ``.text``/``.caption`` empty,
``.api_kwargs`` None). So replies to the launchd briefings / any rich send
arrive with no quotable text and the agent is blind to what was referenced.
Fix: remember ``message_id -> text`` at send time, look it up by
``reply_to_id`` on inbound. This module is the single source of truth for that
index.
Best-effort and dependency-free: every operation swallows errors and degrades
to a no-op / ``None`` so it can never break a send or an inbound message.
"""
from __future__ import annotations
import json
import os
import time
from typing import Optional
_MAX_ENTRIES = 1000
_MAX_TEXT_CHARS = 2000
def _store_path() -> str:
# Resolve via get_hermes_home() so the active profile override is honored.
from hermes_constants import get_hermes_home
home = get_hermes_home()
return os.path.join(str(home), "state", "rich_sent_index.json")
def _key(chat_id, message_id) -> str:
return f"{chat_id}:{message_id}"
def record(chat_id, message_id, text: Optional[str]) -> None:
"""Persist ``text`` for ``(chat_id, message_id)``. No-op on any failure."""
if not text or message_id is None or chat_id is None:
return
path = _store_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if not isinstance(data, dict):
data = {}
except (FileNotFoundError, ValueError):
data = {}
data[_key(chat_id, message_id)] = {
"t": text[:_MAX_TEXT_CHARS],
"ts": int(time.time()),
}
# Trim oldest by timestamp when over cap.
if len(data) > _MAX_ENTRIES:
for k, _ in sorted(
data.items(), key=lambda kv: kv[1].get("ts", 0)
)[: len(data) - _MAX_ENTRIES]:
data.pop(k, None)
tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False)
os.replace(tmp, path) # atomic; tolerates concurrent writers racing
except Exception:
return
def lookup(chat_id, message_id) -> Optional[str]:
"""Return stored text for ``(chat_id, message_id)`` or ``None``."""
if message_id is None or chat_id is None:
return None
try:
with open(_store_path(), "r", encoding="utf-8") as fh:
data = json.load(fh)
entry = data.get(_key(chat_id, message_id))
if isinstance(entry, dict):
return entry.get("t") or None
except (FileNotFoundError, ValueError, AttributeError):
return None
return None
+34847
View File
File diff suppressed because it is too large. Load diff
+187
View File
@@ -0,0 +1,187 @@
"""Gateway runtime-metadata footer.
Renders a compact footer showing runtime state (model, context %, cwd) and
appends it to the FINAL message of an agent turn when enabled. Off by default
to keep replies minimal.
Config (``~/.hermes/config.yaml``)::
display:
runtime_footer:
enabled: true # off by default
fields: [model, context_pct, cwd] # order shown; drop any to hide
Available fields:
model — bare model id, vendor prefix dropped (``gpt-5.4``)
context_pct — last-call context occupancy as a percent (``5%``)
latency — wall-clock duration of the turn (``22s``, ``1m05s``)
cwd — home-relative working dir (``~``)
``latency`` is opt-in: it is NOT in the default field set, so a footer whose
``fields`` are unset renders exactly as before.
Per-platform overrides live under ``display.platforms.<platform>.runtime_footer``.
Users can toggle the global setting with ``/footer on|off`` from both the CLI
and any gateway platform.
The footer is appended to the final response text in ``gateway/run.py`` right
before returning the response to the adapter send path — so it only lands on
the final message a user sees, not on tool-progress updates or streaming
partials. When streaming is on and the final text has already been delivered
piecemeal, the footer is sent as a separate trailing message via
``send_trailing_footer()``.
"""
from __future__ import annotations
import os
from typing import Any, Iterable, Optional
_DEFAULT_FIELDS: tuple[str, ...] = ("model", "context_pct", "cwd")
_SEP = " · "
def _home_relative_cwd(cwd: str) -> str:
"""Return *cwd* with ``$HOME`` collapsed to ``~``. Empty string if unset."""
if not cwd:
return ""
try:
home = os.path.expanduser("~")
p = os.path.abspath(cwd)
if home and (p == home or p.startswith(home + os.sep)):
return "~" + p[len(home):]
return p
except Exception:
return cwd
def _model_short(model: Optional[str]) -> str:
"""Drop ``vendor/`` prefix for readability (``openai/gpt-5.4`` → ``gpt-5.4``)."""
if not model:
return ""
return model.rsplit("/", 1)[-1]
def resolve_footer_config(
user_config: dict[str, Any] | None,
platform_key: str | None = None,
) -> dict[str, Any]:
"""Resolve effective runtime-footer config for *platform_key*.
Merge order (later wins):
1. Built-in defaults (enabled=False)
2. ``display.runtime_footer``
3. ``display.platforms.<platform_key>.runtime_footer``
"""
resolved = {"enabled": False, "fields": list(_DEFAULT_FIELDS)}
cfg = (user_config or {}).get("display") or {}
global_cfg = cfg.get("runtime_footer")
if isinstance(global_cfg, dict):
if "enabled" in global_cfg:
resolved["enabled"] = bool(global_cfg.get("enabled"))
if isinstance(global_cfg.get("fields"), list) and global_cfg["fields"]:
resolved["fields"] = [str(f) for f in global_cfg["fields"]]
if platform_key:
platforms = cfg.get("platforms") or {}
plat_cfg = platforms.get(platform_key)
if isinstance(plat_cfg, dict):
plat_footer = plat_cfg.get("runtime_footer")
if isinstance(plat_footer, dict):
if "enabled" in plat_footer:
resolved["enabled"] = bool(plat_footer.get("enabled"))
if isinstance(plat_footer.get("fields"), list) and plat_footer["fields"]:
resolved["fields"] = [str(f) for f in plat_footer["fields"]]
return resolved
def _format_latency(seconds: float) -> str:
"""Humanize a turn duration: ``<1s``, ``22s``, ``1m05s``."""
if seconds < 1:
return "<1s"
total = int(round(seconds))
if total < 60:
return f"{total}s"
m, sec = divmod(total, 60)
return f"{m}m{sec:02d}s"
def format_runtime_footer(
*,
model: Optional[str],
context_tokens: int,
context_length: Optional[int],
cwd: Optional[str] = None,
turn_seconds: Optional[float] = None,
fields: Iterable[str] = _DEFAULT_FIELDS,
) -> str:
"""Render the footer line, or return "" if no fields have data.
Fields are skipped silently when their underlying data is missing — a
partially-populated footer is better than a line with ``?%`` or empty slots.
"""
parts: list[str] = []
for field in fields:
if field == "model":
m = _model_short(model)
if m:
parts.append(m)
elif field == "context_pct":
if context_length and context_length > 0 and context_tokens >= 0:
pct = max(0, min(100, round((context_tokens / context_length) * 100)))
parts.append(f"{pct}%")
elif field == "latency":
# Wall-clock turn duration. Skipped when the caller supplied no
# timing (call sites that don't measure) or the value is negative.
if turn_seconds is not None and turn_seconds >= 0:
parts.append(_format_latency(turn_seconds))
elif field == "cwd":
try:
from tools.terminal_scope import terminal_env as _tenv
except ImportError:
env_cwd = os.environ.get("TERMINAL_CWD", "")
else:
env_cwd = _tenv("TERMINAL_CWD", "")
rel = _home_relative_cwd(cwd or env_cwd)
if rel:
parts.append(rel)
# Unknown field names are silently ignored.
if not parts:
return ""
return _SEP.join(parts)
def build_footer_line(
*,
user_config: dict[str, Any] | None,
platform_key: str | None,
model: Optional[str],
context_tokens: int,
context_length: Optional[int],
cwd: Optional[str] = None,
turn_seconds: Optional[float] = None,
) -> str:
"""Top-level entry point used by gateway/run.py.
Returns the footer text (empty string when disabled or no data). Callers
append this to the final response themselves, preserving a single blank
line of separation.
``turn_seconds`` is the wall-clock duration of the agent run, measured by
the caller with ``time.monotonic()``. Callers that don't measure it leave
it ``None`` and the ``latency`` field is skipped.
"""
cfg = resolve_footer_config(user_config, platform_key)
if not cfg.get("enabled"):
return ""
return format_runtime_footer(
model=model,
context_tokens=context_tokens,
context_length=context_length,
cwd=cwd,
turn_seconds=turn_seconds,
fields=cfg.get("fields") or _DEFAULT_FIELDS,
)
+314
View File
@@ -0,0 +1,314 @@
"""Scale-to-zero idle detection + dormant-quiesce for the gateway (Phase 0).
This is the gateway-side BEHAVIOUR layer that consumes the relay scale-to-zero
PRIMITIVES (gateway-gateway Phase 5: the buffered-flip, the durable per-instance
buffer, the wakeUrl poke, the reconnect supervisor). It owns the *decision* to go
idle, drives the relay transport's ``go_dormant()`` (D12), and then SUSPENDS the
machine itself through the local Fly Machines API socket. Wake stays platform-side:
autostart-on-wakeUrl (decisions.md Q3=C′).
Why the gateway self-suspends instead of relying on ``autostop:"suspend"``: Fly
Proxy judges idle exclusively on INBOUND proxied connections — it cannot see an
in-flight agent turn (outbound-only LLM traffic) and there is no way for the app
to signal "not ready to suspend". Mid-2026 the proxy also stopped treating open
OUTBOUND sockets as activity, so the relay WebSocket no longer masks the race:
Fly would suspend a machine mid-job, and could suspend BEFORE ``go_dormant()``
flipped the relay destination (the buffered-event black hole). Owning the suspend
call closes both: it only ever fires after the idle predicate (no running agents,
no live background work, inbound-quiet) holds AND the dormant quiesce completed.
Design constraints (decisions.md):
- Per-instance enable is gated SOLELY by the NAS "Labs" toggle, carried to the
gateway as the ``HERMES_SCALE_TO_ZERO`` env stamp (D11/Q8=A). NOT a user
config key; ``scale_to_zero.idle_timeout_minutes`` IS config.yaml (D2).
- Arm only when messaging is relay-only or absent (D1/F6) AND a wakeUrl is
registered (§3.4(1)) AND the flag is set.
- Idle = no in-flight agent turn AND no inbound for N min AND no live
background work (D2/D3/F7).
- The quiesce uses ``go_dormant()`` (socket closed + supervisor preserved),
NEVER the stop/restart drain or ``disconnect()`` (F12/F14). The process stays
alive; Fly freezes+resumes it.
- ``mark_resume_pending`` is deliberately NOT called here (D13 — suspend
preserves RAM; revive only if we move to autostop:"stop" or see kills).
The pure helpers (``parse_idle_timeout_seconds``, ``scale_to_zero_enabled``,
``messaging_is_relay_only_or_absent``, ``is_idle``, ``should_arm``) take plain
inputs so they unit-test without a live gateway.
"""
from __future__ import annotations
import json
import logging
import os
import socket
from typing import Any, Iterable, Optional
logger = logging.getLogger(__name__)
# Env flag stamped by NAS when the scaleToZero Labs toggle is on (D11/Q8=A),
# mirroring how the `relay` feature stamps GATEWAY_RELAY_URL. Truthy values only.
SCALE_TO_ZERO_ENV = "HERMES_SCALE_TO_ZERO"
# Fly-injected machine identity (present on every Fly machine). Used by the
# self-suspend call; both must be present for self_suspend_available().
FLY_APP_NAME_ENV = "FLY_APP_NAME"
FLY_MACHINE_ID_ENV = "FLY_MACHINE_ID"
# The local flaps (Fly Machines API) unix socket, available inside every Fly
# machine. A POST to /v1/apps/{app}/machines/{id}/suspend snapshots RAM and
# suspends THIS machine — the Fly-endorsed replacement for proxy autostop when
# the app must own the idle decision (https://fly.io/docs/reference/suspend-resume/).
FLY_API_SOCKET = "/.fly/api"
# config.yaml default (D2). Behavioural setting -> config, not env.
# 2 minutes: with the gateway owning the suspend (idle predicate covers agent
# turns, cron, API runs, and background work; the relay drains + flips before
# the freeze), a short window is safe — real work always blocks the suspend and
# resume-from-suspend is sub-second, so the only cost of waking "too eagerly"
# after a quiet spell is a Fly-proxied poke away. Longer windows just bill idle
# RAM. Raise per-instance via gateway.scale_to_zero.idle_timeout_minutes.
DEFAULT_IDLE_TIMEOUT_MINUTES = 2
_TRUTHY = {"1", "true", "yes", "on"}
def scale_to_zero_enabled(environ: Optional[dict] = None) -> bool:
"""Whether the per-instance Labs toggle is on (the HERMES_SCALE_TO_ZERO stamp).
D11/Q8=A: this env flag is the SOLE per-instance enable signal reaching the
gateway. Absent/blank/falsey -> disabled (fail-safe default off).
"""
env = environ if environ is not None else os.environ
return str(env.get(SCALE_TO_ZERO_ENV, "")).strip().lower() in _TRUTHY
def parse_idle_timeout_seconds(
cfg_value: Any, default_minutes: int = DEFAULT_IDLE_TIMEOUT_MINUTES
) -> float:
"""Coerce ``scale_to_zero.idle_timeout_minutes`` (config.yaml, D2) to seconds.
Degrades to the default on any non-numeric / non-positive value (never raises,
never returns <= 0 — a zero/negative timeout would make the gateway go dormant
instantly, which is never the intent).
"""
try:
minutes = float(cfg_value)
except (TypeError, ValueError):
minutes = float(default_minutes)
if minutes <= 0:
minutes = float(default_minutes)
return minutes * 60.0
def messaging_is_relay_only_or_absent(platforms: Iterable[Any]) -> bool:
"""True iff the only connected messaging platform is RELAY, or there is none
(a Chronos-only / no-platform agent) — the F6/D1 structural precondition.
A directly-connected platform (Discord/Telegram/Slack/...) holds a live
socket and cannot scale to zero, so its presence disarms the feature. We
compare by the platform's ``.value``/name to avoid importing the enum here
(keeps this module import-light and unit-testable).
"""
names = {_platform_name(p) for p in platforms}
names.discard("relay")
return len(names) == 0
def _platform_name(platform: Any) -> str:
value = getattr(platform, "value", platform)
return str(value).strip().lower()
def should_arm(
*,
enabled: bool,
relay_only_or_absent: bool,
wake_url: Optional[str],
) -> bool:
"""Whether to start the idle watcher at all (D1/D11/§3.4(1)).
ALL must hold: the Labs flag is on, messaging is relay-only/absent, and a
wakeUrl is registered (a suspended instance with no reachable wake target is
a black hole — §3.4(1)). Any unmet -> the watcher never starts (no idle
timer, no dormancy), so a non-opted instance behaves exactly as today.
"""
return bool(enabled) and bool(relay_only_or_absent) and bool(wake_url)
def is_idle(
*,
active_work_count: int,
seconds_since_last_inbound: float,
idle_timeout_seconds: float,
has_live_background_work: bool,
) -> bool:
"""The idle predicate (D2/D3/F7). Pure — composes the three conjuncts.
Idle iff: no counted active work (in-flight agent turns + cron jobs +
API-server runs — the caller aggregates every foreground work source),
no inbound within the timeout window, and no live background work
(backgrounded delegate_task / kanban / bg terminal). Any active work
keeps the gateway awake — suspending mid-flight would lose it.
``active_work_count`` deliberately names the BROAD aggregate, not just
agents: a caller passing only ``len(_running_agents)`` reopens the
mid-cron-job suspend hole. Callers that cannot read a work source must
fail AWAKE (pass a positive sentinel), never fail to 0.
"""
if active_work_count > 0:
return False
if has_live_background_work:
return False
return seconds_since_last_inbound >= idle_timeout_seconds
# Dashboard-client liveness marker. The dashboard process (tui_gateway/ws.py,
# a DIFFERENT process from the gateway on hosted instances) touches this file
# on every /api/ws connect and inbound frame — the desktop app, web dashboard
# and TUI all send `gateway.ping` every 15s (apps/shared/src/json-rpc-gateway.ts,
# ui-tui/src/gatewayClient.ts). The gateway folds the mtime into its inbound
# clock, so an open client holds the box awake exactly like a chat message does
# and gets the same idle_timeout grace after it disconnects. Without this the
# box suspends under the open client, the client's reconnect loop re-pokes the
# Fly-proxied hostname, autostart resumes it, and the instance flaps every ~60s
# (13 of 72 active opted-in prod instances, 2026-09-02).
#
# There is deliberately NO staleness cutoff here: the mtime is a timestamp of
# real inbound, and is_idle already decides whether it is recent enough. A
# lingering marker cannot pin the box — once it is older than idle_timeout it
# no longer counts, exactly like an old _last_inbound_at.
DASHBOARD_CLIENT_HEARTBEAT_REL = os.path.join("state", "dashboard_clients.heartbeat")
def dashboard_client_heartbeat_path(hermes_home: Optional[os.PathLike | str] = None):
"""Path of the dashboard-client liveness marker under HERMES_HOME."""
from pathlib import Path
if hermes_home is None:
from hermes_constants import get_hermes_home
hermes_home = get_hermes_home()
return Path(hermes_home) / DASHBOARD_CLIENT_HEARTBEAT_REL
def touch_dashboard_client_heartbeat(path: Optional[os.PathLike | str] = None) -> bool:
"""Mark "a dashboard client is attached right now". Best-effort, never raises."""
try:
p = dashboard_client_heartbeat_path() if path is None else path
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "a", encoding="utf-8"):
pass
os.utime(p, None)
return True
except Exception: # noqa: BLE001 - liveness garnish must never break the WS
logger.debug("scale-to-zero: dashboard heartbeat touch failed", exc_info=True)
return False
def dashboard_client_last_seen(
path: Optional[os.PathLike | str] = None,
*,
now: Optional[float] = None,
) -> Optional[float]:
"""Epoch seconds a dashboard client last sent a WS frame, or None if never.
Missing marker -> None (the steady state on a box nobody has the dashboard
open on — NOT fail-awake, or every instance would never sleep). An
unreadable marker -> ``now`` (fail-awake: an unreadable source counts as
activity, same rule as the work counters in ``is_idle``).
"""
import time
current = time.time() if now is None else now
p = dashboard_client_heartbeat_path() if path is None else path
try:
# Clamp to now: a wall-clock step-back (NTP) can leave the mtime in the
# future, which would push idle out by the step size for no reason.
return min(os.stat(p).st_mtime, current)
except FileNotFoundError:
return None
except OSError:
return current
def self_suspend_available(environ: Optional[dict] = None) -> bool:
"""Whether this process can suspend its own machine via the flaps socket.
True iff the Fly-injected machine identity is present AND the local Machines
API socket exists. Off-Fly (local dev, Azure ACA, tests) this is False and
the watcher skips the quiesce entirely: the platform owns the freeze, so
the gateway stays connected until it lands.
"""
env = environ if environ is not None else os.environ
return bool(
str(env.get(FLY_APP_NAME_ENV, "")).strip()
and str(env.get(FLY_MACHINE_ID_ENV, "")).strip()
and os.path.exists(FLY_API_SOCKET)
)
def suspend_self(
environ: Optional[dict] = None,
*,
socket_path: str = FLY_API_SOCKET,
timeout: float = 10.0,
) -> bool:
"""POST /v1/apps/{app}/machines/{id}/suspend on the local flaps socket.
Fly's in-machine Machines API needs no token — the socket itself is the
credential. Equivalent to:
curl --unix-socket /.fly/api -X POST \\
http://flaps/v1/apps/$FLY_APP_NAME/machines/$FLY_MACHINE_ID/suspend
Returns True when flaps accepted the request (2xx). The caller should treat
this as fire-and-forget: on success the kernel freezes this process shortly
after, so there may be nothing meaningful to run afterwards. Never raises —
a failed suspend just leaves the machine running (fail-awake, never
fail-frozen), which costs money but loses no work.
stdlib-only on purpose: a plain unix-socket HTTP/1.1 request, no httpx/
requests dependency in the hot path and no async plumbing to freeze
mid-await.
"""
env = environ if environ is not None else os.environ
app = str(env.get(FLY_APP_NAME_ENV, "")).strip()
machine_id = str(env.get(FLY_MACHINE_ID_ENV, "")).strip()
if not app or not machine_id:
logger.warning("scale-to-zero: suspend_self called without Fly machine identity")
return False
request = (
f"POST /v1/apps/{app}/machines/{machine_id}/suspend HTTP/1.1\r\n"
"Host: flaps\r\n"
"Content-Length: 0\r\n"
"Connection: close\r\n"
"\r\n"
)
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
sock.connect(socket_path)
sock.sendall(request.encode("ascii"))
response = b""
while len(response) < 65536:
chunk = sock.recv(4096)
if not chunk:
break
response += chunk
except OSError as exc:
logger.warning("scale-to-zero: flaps suspend request failed: %s", exc)
return False
status_line = response.split(b"\r\n", 1)[0].decode("ascii", "replace")
parts = status_line.split()
ok = len(parts) >= 2 and parts[1].isdigit() and 200 <= int(parts[1]) < 300
if ok:
logger.info("scale-to-zero: machine suspend accepted by flaps (%s)", status_line)
else:
body = response.split(b"\r\n\r\n", 1)[-1][:500].decode("utf-8", "replace")
logger.warning(
"scale-to-zero: flaps suspend rejected: %s %s",
status_line,
json.dumps(body)[:500],
)
return ok
+4574
View File
File diff suppressed because it is too large. Load diff
+525
View File
@@ -0,0 +1,525 @@
"""
Session-scoped context variables for the Hermes gateway.
Replaces the previous ``os.environ``-based session state
(``HERMES_SESSION_PLATFORM``, ``HERMES_SESSION_CHAT_ID``, etc.) with
Python's ``contextvars.ContextVar``.
**Why this matters**
The gateway processes messages concurrently via ``asyncio``. When two
messages arrive at the same time the old code did:
os.environ["HERMES_SESSION_THREAD_ID"] = str(context.source.thread_id)
Because ``os.environ`` is *process-global*, Message A's value was
silently overwritten by Message B before Message A's agent finished
running. Background-task notifications and tool calls therefore routed
to the wrong thread.
``contextvars.ContextVar`` values are *task-local*: each ``asyncio``
task (and any ``run_in_executor`` thread it spawns) gets its own copy,
so concurrent messages never interfere.
**Backward compatibility**
The public helper ``get_session_env(name, default="")`` mirrors the old
``os.getenv("HERMES_SESSION_*", ...)`` calls. Existing tool code only
needs to replace the import + call site:
# before
import os
platform = os.getenv("HERMES_SESSION_PLATFORM", "")
# after
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
"""
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Iterator
# Sentinel to distinguish "never set in this context" from "explicitly set to empty".
# When a contextvar holds _UNSET, we fall back to os.environ (CLI/cron compat).
# When it holds "" (after clear_session_vars resets it), we return "" — no fallback.
_UNSET: Any = object()
# Process-level flag: has any code in this process bound a session via
# set_session_vars()? Concurrent multi-session hosts (the messaging gateway, the
# ACP adapter, the API server, the TUI, cron) all do; a pure single-process
# CLI/one-shot that never engages the session-context system does not.
#
# The subprocess-env bridge (tools/environments/local.py) reads this to choose
# its leak policy: when engaged, the ContextVars are authoritative and an _UNSET
# var means "no session bound in THIS task" — so a process-global os.environ
# mirror (written last-writer-wins by whatever concurrent session ran most
# recently) must NOT be inherited into a child process. When never engaged, the
# os.environ fallback is preserved (no concurrency to leak across). Monotonic
# latch — once any host binds a session, the process stays engaged for life.
_session_context_engaged: bool = False
def session_context_engaged() -> bool:
"""True if any session has been bound via set_session_vars in this process.
See the ``_session_context_engaged`` comment for the leak-policy rationale.
"""
return _session_context_engaged
# ---------------------------------------------------------------------------
# Per-task session variables
# ---------------------------------------------------------------------------
_SESSION_PLATFORM: ContextVar = ContextVar("HERMES_SESSION_PLATFORM", default=_UNSET)
_SESSION_SOURCE: ContextVar = ContextVar("HERMES_SESSION_SOURCE", default=_UNSET)
_SESSION_CHAT_ID: ContextVar = ContextVar("HERMES_SESSION_CHAT_ID", default=_UNSET)
_SESSION_CHAT_TYPE: ContextVar = ContextVar("HERMES_SESSION_CHAT_TYPE", default=_UNSET)
_SESSION_CHAT_NAME: ContextVar = ContextVar("HERMES_SESSION_CHAT_NAME", default=_UNSET)
_SESSION_THREAD_ID: ContextVar = ContextVar("HERMES_SESSION_THREAD_ID", default=_UNSET)
_SESSION_USER_ID: ContextVar = ContextVar("HERMES_SESSION_USER_ID", default=_UNSET)
_SESSION_USER_ID_ALT: ContextVar = ContextVar("HERMES_SESSION_USER_ID_ALT", default=_UNSET)
_SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET)
# Platform-neutral scope discriminator (Discord guild / Slack workspace /
# Matrix server) of the originating chat. Captured at session-bind time so
# async producers (delegate_task background=True, terminal watchers) can
# persist a completion's full routing origin — on a relay-fronted deployment
# the connector's fail-closed egress guard needs scope_id (or a user binding)
# to resolve the tenant for a scoped reply after a restart.
_SESSION_SCOPE_ID: ContextVar = ContextVar("HERMES_SESSION_SCOPE_ID", default=_UNSET)
_SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET)
_SESSION_ID: ContextVar = ContextVar("HERMES_SESSION_ID", default=_UNSET)
# In-process UI session/window id for multi-session desktop/TUI hosts. This is
# intentionally separate from HERMES_SESSION_ID: the latter is the durable
# conversation/session-db id, while the UI id is the live frontend tab/window
# that commissioned a detached completion. Background completions use it as a
# precise return address so a stale/rotated durable session key cannot be
# consumed by whichever desktop poller wakes first.
_SESSION_UI_SESSION_ID: ContextVar = ContextVar("HERMES_UI_SESSION_ID", default=_UNSET)
# ID of the message that triggered the current turn. Used as a reply anchor
# so background-process notifications stay inside the originating Telegram
# private-chat topic (those lanes route only with thread id + reply anchor).
_SESSION_MESSAGE_ID: ContextVar = ContextVar("HERMES_SESSION_MESSAGE_ID", default=_UNSET)
_SESSION_PROFILE: ContextVar = ContextVar("HERMES_SESSION_PROFILE", default=_UNSET)
_BROWSER_CONTROL_PRINCIPAL: ContextVar = ContextVar(
"HERMES_BROWSER_CONTROL_PRINCIPAL", default=_UNSET
)
_BROWSER_CONTROL_TRANSPORT_FAMILY: ContextVar = ContextVar(
"HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY", default=_UNSET
)
# Per-session cron marker. Unlike the process-global legacy env var, this is
# scoped to one cron job / inbound session. _UNSET preserves the legacy env
# fallback for CLI/tests; "1" marks cron; "" explicitly marks non-cron and
# masks any leaked process env value.
_CRON_SESSION: ContextVar = ContextVar("HERMES_CRON_SESSION", default=_UNSET)
# Whether the current session's delivery channel can route an ASYNC completion
# back to the agent AFTER the current turn ends (i.e. wake a fresh turn).
#
# True — long-lived CLI sessions (in-process completion_queue drain) and the
# real gateway platforms (Telegram/Discord/Slack/...), which hold a
# persistent outbound channel and run the watcher/drain loops.
# False — finite runtimes that can end before a detached completion returns:
# stateless API-server requests and dispatcher-spawned Kanban workers.
#
# Tools that promise async delivery (terminal notify_on_complete /
# watch_patterns, delegate_task background=True) read this via
# ``async_delivery_supported()`` and refuse to hand out a promise the channel
# can't keep — turning a silent no-op into an explicit contract.
#
# Default _UNSET => treated as supported, so CLI (which never sets a platform)
# and any contextvar-unaware path keep working. Stateless adapters opt OUT by
# setting ``supports_async_delivery = False`` on the adapter class; the gateway
# propagates that into this contextvar at session-bind time.
_SESSION_ASYNC_DELIVERY: ContextVar = ContextVar("HERMES_SESSION_ASYNC_DELIVERY", default=_UNSET)
# Cron auto-delivery vars — set per-job in run_job() so concurrent jobs
# don't clobber each other's delivery targets.
_CRON_AUTO_DELIVER_PLATFORM: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_PLATFORM", default=_UNSET)
_CRON_AUTO_DELIVER_CHAT_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_CHAT_ID", default=_UNSET)
_CRON_AUTO_DELIVER_THREAD_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_THREAD_ID", default=_UNSET)
_VAR_MAP = {
"HERMES_SESSION_PLATFORM": _SESSION_PLATFORM,
"HERMES_SESSION_SOURCE": _SESSION_SOURCE,
"HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID,
"HERMES_SESSION_CHAT_TYPE": _SESSION_CHAT_TYPE,
"HERMES_SESSION_CHAT_NAME": _SESSION_CHAT_NAME,
"HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID,
"HERMES_SESSION_USER_ID": _SESSION_USER_ID,
"HERMES_SESSION_USER_ID_ALT": _SESSION_USER_ID_ALT,
"HERMES_SESSION_USER_NAME": _SESSION_USER_NAME,
"HERMES_SESSION_SCOPE_ID": _SESSION_SCOPE_ID,
"HERMES_SESSION_KEY": _SESSION_KEY,
"HERMES_SESSION_ID": _SESSION_ID,
"HERMES_UI_SESSION_ID": _SESSION_UI_SESSION_ID,
"HERMES_SESSION_MESSAGE_ID": _SESSION_MESSAGE_ID,
"HERMES_SESSION_PROFILE": _SESSION_PROFILE,
"HERMES_BROWSER_CONTROL_PRINCIPAL": _BROWSER_CONTROL_PRINCIPAL,
"HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY": _BROWSER_CONTROL_TRANSPORT_FAMILY,
"HERMES_CRON_SESSION": _CRON_SESSION,
"HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM,
"HERMES_CRON_AUTO_DELIVER_CHAT_ID": _CRON_AUTO_DELIVER_CHAT_ID,
"HERMES_CRON_AUTO_DELIVER_THREAD_ID": _CRON_AUTO_DELIVER_THREAD_ID,
}
def set_current_session_id(session_id: str) -> None:
"""Synchronize ``HERMES_SESSION_ID`` across ContextVar and ``os.environ``.
Long-lived single-process entrypoints like the CLI can rotate sessions via
``/new``, ``/resume``, ``/branch``, or compression splits without
reconstructing the entire agent. Tools still consult
``get_session_env("HERMES_SESSION_ID")`` with an ``os.environ`` fallback,
so both storage paths must move together when the active session changes.
Delegated subagent children are the exception: they are constructed inside
the parent process within ``delegated_child_context()``, and their
``AIAgent.__init__`` calls this same helper. Writing a child's internal
session id to ``os.environ`` (process-global) would clobber the parent's
``HERMES_SESSION_ID`` for the rest of the process — leaking the child id
into parent tools and subprocesses spawned after the child was built. The
ContextVar write below is task-local and safe for concurrent children; only
the process-global ``os.environ`` mirror is suppressed for delegated
children. Root agents (CLI, gateway, cron) keep both paths.
"""
import os
_SESSION_ID.set(session_id)
# Skip the process-global os.environ write for delegated children. The
# child's own tools and subprocesses still resolve their id through the
# ContextVar (task-local), while the parent's process-wide env keeps the
# parent's session identity. See HermesPRDelegationSessionContext task.
try:
from agent.delegation_context import is_delegated_child_context
if is_delegated_child_context():
return
except Exception:
pass
os.environ["HERMES_SESSION_ID"] = session_id
@contextmanager
def scoped_current_session_id(session_id: str | None = None) -> Iterator[None]:
"""Bind a task-local session id and restore the prior value on exit.
With ``session_id=None`` this acts as a save/restore boundary around code
that may call :func:`set_current_session_id` itself (notably delegated
``AIAgent`` construction). It intentionally never mutates ``os.environ``.
"""
previous = _SESSION_ID.get()
if session_id is not None:
_SESSION_ID.set(session_id)
try:
yield
finally:
_SESSION_ID.set(previous)
def set_session_vars(
platform: str = "",
source: str = "",
chat_id: str = "",
chat_type: str = "",
chat_name: str = "",
thread_id: str = "",
user_id: str = "",
user_id_alt: str = "",
user_name: str = "",
scope_id: str = "",
session_key: str = "",
session_id: str = "",
message_id: str = "",
profile: str = "",
browser_control_principal: str = "",
browser_control_transport_family: str = "",
cwd: str = "",
async_delivery: bool = True,
ui_session_id: str = "",
cron_session: Any = _UNSET,
) -> list:
"""Set all session context variables and return reset tokens.
Call ``clear_session_vars(tokens)`` in a ``finally`` block when the handler
exits. Note ``clear_session_vars`` resets every var to ``""`` (to suppress
the ``os.environ`` fallback) rather than restoring prior values — these
helpers are not nestable/stack-safe, and the returned tokens are accepted
only for API compatibility.
``cwd`` pins the logical working directory for this context.
``async_delivery`` declares whether this session's channel can route a
background completion back to the agent after the turn ends (see
``_SESSION_ASYNC_DELIVERY`` / ``async_delivery_supported``). Stateless
request/response adapters (the API server) pass ``False``.
``cron_session`` is tri-state: ``_UNSET`` preserves legacy
``os.environ["HERMES_CRON_SESSION"]`` fallback, ``"1"`` marks a cron job,
and ``""`` explicitly marks a non-cron session while masking leaked env.
"""
# Mark the session-context machinery engaged for this process. The
# subprocess-env bridge uses this to switch from "os.environ fallback" to
# "ContextVar-authoritative, strip on _UNSET" — see session_context_engaged.
global _session_context_engaged
_session_context_engaged = True
tokens = [
_SESSION_PLATFORM.set(platform),
_SESSION_SOURCE.set(source),
_SESSION_CHAT_ID.set(chat_id),
_SESSION_CHAT_TYPE.set(chat_type),
_SESSION_CHAT_NAME.set(chat_name),
_SESSION_THREAD_ID.set(thread_id),
_SESSION_USER_ID.set(user_id),
_SESSION_USER_ID_ALT.set(user_id_alt),
_SESSION_USER_NAME.set(user_name),
_SESSION_SCOPE_ID.set(scope_id),
_SESSION_KEY.set(session_key),
_SESSION_ID.set(session_id),
_SESSION_UI_SESSION_ID.set(ui_session_id),
_SESSION_MESSAGE_ID.set(message_id),
_SESSION_PROFILE.set(profile),
_BROWSER_CONTROL_PRINCIPAL.set(browser_control_principal),
_BROWSER_CONTROL_TRANSPORT_FAMILY.set(browser_control_transport_family),
_CRON_SESSION.set(cron_session),
_SESSION_ASYNC_DELIVERY.set(bool(async_delivery)),
]
try:
from agent.runtime_cwd import set_session_cwd
set_session_cwd(cwd)
except Exception:
pass
return tokens
def clear_session_vars(tokens: list) -> None:
"""Mark session context variables as explicitly cleared.
Sets all variables to ``""`` so that ``get_session_env`` returns an empty
string instead of falling back to (potentially stale) ``os.environ``
values. The *tokens* argument is accepted for API compatibility with
callers that saved the return value of ``set_session_vars``, but the
actual clearing uses ``var.set("")`` rather than ``var.reset(token)``
to ensure the "explicitly cleared" state is distinguishable from
"never set" (which holds the ``_UNSET`` sentinel).
"""
for var in (
_SESSION_PLATFORM,
_SESSION_SOURCE,
_SESSION_CHAT_ID,
_SESSION_CHAT_TYPE,
_SESSION_CHAT_NAME,
_SESSION_THREAD_ID,
_SESSION_USER_ID,
_SESSION_USER_ID_ALT,
_SESSION_USER_NAME,
_SESSION_SCOPE_ID,
_SESSION_KEY,
_SESSION_ID,
_SESSION_UI_SESSION_ID,
_SESSION_MESSAGE_ID,
_SESSION_PROFILE,
_BROWSER_CONTROL_PRINCIPAL,
_BROWSER_CONTROL_TRANSPORT_FAMILY,
_CRON_SESSION,
):
var.set("")
# Reset async-delivery capability to the "never set" sentinel rather than a
# falsy value: a cleared context should fall back to the default-supported
# behavior (CLI / unaware paths), not be mistaken for an opted-out
# stateless adapter.
_SESSION_ASYNC_DELIVERY.set(_UNSET)
try:
from agent.runtime_cwd import clear_session_cwd
clear_session_cwd()
except Exception:
pass
def reset_session_vars() -> None:
"""Reset every session context variable to ``_UNSET`` for THIS context.
Distinct from :func:`clear_session_vars`, which sets the vars to ``""``
("explicitly cleared" — suppresses the os.environ fallback and is used when
a handler *finishes*). This helper restores the ``_UNSET`` sentinel
("never bound in this context"), which is what a freshly-spawned task should
look like *before* it binds its own session.
🔴 Why this exists — the cross-session ContextVar inheritance leak.
Each gateway message is processed in its own ``asyncio`` task, created via
``create_task`` (which snapshots the *current* context with
``copy_context``). When message B's task is spawned from a context where a
concurrent message A had already called :func:`set_session_vars`, B inherits
A's **set** ContextVars. Until B calls its own ``set_session_vars`` there is
a window where any subprocess B spawns (e.g. a tool shelling out) reads
*A's* ``HERMES_SESSION_*`` identity via the subprocess-env bridge. The
bridge's ``_UNSET``-strip guard cannot help: the vars are not ``_UNSET``,
they are set-to-A. Calling ``reset_session_vars`` at the top of the
per-message handler drops the inherited identity so the window strips safe
(no session) instead of leaking the foreign one; the handler then binds its
own via ``set_session_vars`` a few steps later. See
tests/tools/test_local_env_session_leak.py and
tests/gateway/test_session_context_inheritance.py.
Note ``_SESSION_ASYNC_DELIVERY`` lives outside ``_VAR_MAP`` (it is a bool
capability flag read via :func:`async_delivery_supported`, not a string
``HERMES_SESSION_*`` env var read via :func:`get_session_env`), so it is
reset explicitly below. Without it, a task spawned from a context where a
sibling adapter bound ``async_delivery=False`` (the stateless API server)
inherits that ``False`` through the pre-bind window, and
``async_delivery_supported`` wrongly reports the new turn's channel as
unable to route a background completion until ``set_session_vars`` runs.
"""
for var in _VAR_MAP.values():
var.set(_UNSET)
# Reset the async-delivery capability to "never bound here" (_UNSET) for the
# same inheritance-leak reason as the mapped vars above — see clear_session_vars,
# which resets this var on the handler-exit path for the symmetric concern.
_SESSION_ASYNC_DELIVERY.set(_UNSET)
try:
from agent.runtime_cwd import clear_session_cwd
clear_session_cwd()
except Exception:
pass
def get_session_env(name: str, default: str = "") -> str:
"""Read a session context variable by its legacy ``HERMES_SESSION_*`` name.
Drop-in replacement for ``os.getenv("HERMES_SESSION_*", default)``.
Resolution order:
1. Context variable (set by the gateway for concurrency-safe access).
If the variable was explicitly set (even to ``""``) via
``set_session_vars`` or ``clear_session_vars``, that value is
returned — **no fallback to os.environ**.
2. ``os.environ`` (only when the context variable was never set in
this context — i.e. CLI, cron scheduler, and test processes that
don't use ``set_session_vars`` at all).
3. *default*
"""
import os
var = _VAR_MAP.get(name)
if var is not None:
value = var.get()
if value is not _UNSET:
return value
# Fall back to os.environ for CLI, cron, and test compatibility
return os.getenv(name, default)
# Surfaces that are not a human chat channel. The gateway binds a platform
# value (``telegram``) to HERMES_SESSION_PLATFORM, while the CLI, TUI, and
# desktop bind HERMES_SESSION_SOURCE (``cli``, ``tui``, ``desktop``) and leave
# the platform empty — so both have to be consulted. ``local``, ``api_server``,
# ``webhook``, and ``msgraph_webhook`` are real Platform values that reach
# HERMES_SESSION_PLATFORM but have no attachment channel behind them.
# Default-deny: an unrecognized identity counts as messaging so a newly added
# chat platform is never treated as a private surface before this set is
# updated. Mirrors LOCAL_SESSION_SOURCE_IDS in
# apps/desktop/src/lib/session-source.ts; keep roughly in sync when adding a
# local or programmatic surface.
NON_MESSAGING_SESSION_SURFACES = frozenset(
{
"",
"api_server",
"cli",
"codex",
"desktop",
"gateway",
"kanban",
"local",
"msgraph_webhook",
"tool",
"tui",
"webhook",
}
)
def session_is_messaging_surface() -> bool:
"""Whether this turn is delivered over a human messaging channel.
Callers use this to decide anything that differs between "the user is
reading a chat message" and "the user is at a machine they own": whether
to emit a delivery tag, whether a file has to land somewhere the gateway
is allowed to send from, whether narration would read as chat noise.
Resolves ``HERMES_PLATFORM``, then the session platform, then the session
source, and reports messaging when any of them names a surface outside
:data:`NON_MESSAGING_SESSION_SURFACES`.
"""
import os
platform = os.getenv("HERMES_PLATFORM") or get_session_env("HERMES_SESSION_PLATFORM", "")
source = get_session_env("HERMES_SESSION_SOURCE", "")
for identity in (platform, source):
identity = str(identity or "").strip().lower()
if identity and identity not in NON_MESSAGING_SESSION_SURFACES:
return True
return False
def declare_stateless_channel() -> None:
"""Declare that this session cannot receive an async background completion.
Binds only the delivery capability, leaving every other session var unset.
Use this instead of ``set_session_vars(async_delivery=False)`` on a pure
single-process runner: ``set_session_vars`` also latches
``_session_context_engaged`` (see above), which switches the subprocess
env bridge from "os.environ fallback" to "ContextVar-authoritative, strip on
_UNSET" in ``tools/environments/local.py``. A one-shot CLI that never engages
the session-context system must not flip that latch as a side effect of
declaring a capability.
Callers that already build a full session context (cron's ``run_job``) get
the same state by passing ``async_delivery=False`` to ``set_session_vars``.
A session that cannot take a late completion makes ``delegate_task`` fall
through to its existing inline/synchronous path, so subagent results are
returned within the turn instead of being dispatched to a channel that will
never deliver them.
See NousResearch/hermes-agent#53027 and #63142.
"""
_SESSION_ASYNC_DELIVERY.set(False)
def async_delivery_supported() -> bool:
"""Whether the current session can deliver a background completion later.
Returns ``False`` for finite runtimes that can end before a detached result
is delivered: sessions explicitly bound by a stateless channel — an adapter
that cannot route a notification back after the turn ends (the API server),
or a one-shot runner that exits after its final response (``hermes -z``,
cron — see :func:`declare_stateless_channel`) — and dispatcher-spawned
Kanban workers (identified by ``HERMES_KANBAN_TASK``), which are one-shot
``chat -q`` subprocesses. The real gateway platforms, the interactive CLI,
and any other path that never bound the contextvar return ``True``.
Tools that promise async delivery (``terminal`` notify_on_complete /
watch_patterns, ``delegate_task`` background=True) consult this before
registering a watcher / dispatching a detached child, so they can refuse a
promise the channel can't keep instead of silently no-op'ing.
"""
import os
# A Kanban worker is a one-shot subprocess. Its parent session and process
# disappear after the quiet turn returns, so a completion queued later has
# no durable consumer even though an ordinary CLI session can drain that
# queue. Force tools onto their existing synchronous/polling fallbacks.
if os.environ.get("HERMES_KANBAN_TASK"):
return False
value = _SESSION_ASYNC_DELIVERY.get()
if value is _UNSET:
return True
return bool(value)
+189
View File
@@ -0,0 +1,189 @@
"""Recoverable per-path SessionDB handle caches for the gateway."""
from __future__ import annotations
import threading
import time
import weakref
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
_INITIAL_RETRY_DELAY_SECONDS = 1.0
_MAX_RETRY_DELAY_SECONDS = 60.0
@dataclass
class _Unavailable:
failures: int = 0
next_retry_at: float = 0.0
in_flight: bool = False
class _HealthSource:
pass
_health_lock = threading.Lock()
_health_states: weakref.WeakKeyDictionary[_HealthSource, dict[Path, str]] = (
weakref.WeakKeyDictionary()
)
def _publish_health(source: _HealthSource, path: Path, state: str) -> None:
"""Publish one privacy-safe aggregate for every live gateway DB cache."""
with _health_lock:
states = _health_states.setdefault(source, {})
states[path] = state
all_states = [value for item in _health_states.values() for value in item.values()]
if "retrying" in all_states:
aggregate = "retrying"
elif "unavailable" in all_states:
aggregate = "unavailable"
else:
aggregate = "ok"
try:
from gateway.status import write_runtime_status
write_runtime_status(session_store={"status": aggregate})
except Exception:
# Runtime health is diagnostic only; persistence must not depend on it.
pass
class RecoverableHandleCache:
"""Cache handles by path while allowing failed opens to heal in-process."""
def __init__(
self,
*,
handles: dict[Path, Any] | None = None,
lock: threading.Lock | None = None,
clock: Callable[[], float] = time.monotonic,
initial_retry_delay: float = _INITIAL_RETRY_DELAY_SECONDS,
max_retry_delay: float = _MAX_RETRY_DELAY_SECONDS,
) -> None:
self.handles = handles if handles is not None else {}
self.lock = lock if lock is not None else threading.Lock()
self._clock = clock
self._initial_retry_delay = max(0.0, float(initial_retry_delay))
self._max_retry_delay = max(
self._initial_retry_delay, float(max_retry_delay)
)
self._unavailable: dict[Path, _Unavailable] = {}
self._health_source = _HealthSource()
self._generation = 0
self._close_rejected: Callable[[Any], None] | None = None
def get(
self,
path: Path,
opener: Callable[[], Any],
*,
raise_on_error: bool = False,
on_recovered: Callable[[], None] | None = None,
non_cacheable: Callable[[Exception], bool] | None = None,
) -> Any:
"""Return a cached handle or make one bounded, single-flight open attempt."""
path = Path(path)
with self.lock:
if path in self.handles:
return self.handles[path]
unavailable = self._unavailable.setdefault(path, _Unavailable())
now = self._clock()
if unavailable.in_flight or now < unavailable.next_retry_at:
return None
unavailable.in_flight = True
was_unavailable = unavailable.failures > 0
generation = self._generation
if was_unavailable:
_publish_health(self._health_source, path, "retrying")
try:
handle = opener()
except Exception as exc:
if non_cacheable is not None and non_cacheable(exc):
with self.lock:
if (
generation == self._generation
and self._unavailable.get(path) is unavailable
):
self._unavailable.pop(path, None)
raise
with self.lock:
current = self._unavailable.get(path)
stale = generation != self._generation or current is not unavailable
if not stale:
unavailable.failures += 1
delay = min(
self._initial_retry_delay
* (2 ** min(unavailable.failures - 1, 30)),
self._max_retry_delay,
)
unavailable.next_retry_at = self._clock() + delay
unavailable.in_flight = False
if stale:
if raise_on_error:
raise
return None
_publish_health(self._health_source, path, "unavailable")
if raise_on_error:
raise
return None
with self.lock:
stale = (
generation != self._generation
or self._unavailable.get(path) is not unavailable
)
if stale:
close_rejected = self._close_rejected
else:
self.handles[path] = handle
self._unavailable.pop(path, None)
close_rejected = None
if stale:
if close_rejected is not None:
try:
close_rejected(handle)
except Exception:
pass
return None
_publish_health(self._health_source, path, "ok")
if was_unavailable and on_recovered is not None:
on_recovered()
return handle
def close_all(self, close: Callable[[Any], None]) -> None:
"""Drain cached handles under the lock and close them outside it."""
with self.lock:
self._generation += 1
self._close_rejected = close
handles = list(self.handles.values())
paths = set(self.handles) | set(self._unavailable)
self.handles.clear()
self._unavailable.clear()
for handle in handles:
try:
close(handle)
except Exception:
pass
with _health_lock:
states = _health_states.get(self._health_source)
if states is not None:
for path in paths:
states.pop(path, None)
def status_for(self, path: Path) -> str:
"""Return a sanitized state for tests and internal diagnostics."""
with self.lock:
if Path(path) in self.handles:
return "ok"
unavailable = self._unavailable.get(Path(path))
if unavailable is None:
return "unknown"
return "retrying" if unavailable.in_flight else "unavailable"
+125
View File
@@ -0,0 +1,125 @@
"""Gateway session stall notification policy (#72016 item 2).
Consumes the shared activity observation contract from
``agent.session_activity`` / ``AIAgent.get_activity_summary()``
(#72039) as the **single progress source**. This module owns only the
notify-once policy for "pending inbound + stale progress"; it does not
invent a parallel progress clock from turn-start or inbound event
timestamps.
Boundaries (keep separate):
- ``gateway/shutdown_watchdog.py`` — process / event-loop liveness
- ``gateway/delivery_ledger.py`` — outbound delivery obligations
- Pending inbound here is a stall *policy gate* (queued follow-up exists),
not an outbound obligation and not a progress timestamp.
Notification / timeout / kill / retry policy stay in their own components;
the shared contract remains observation-only (timestamp + bounded
description + provenance).
"""
from __future__ import annotations
import math
from typing import Any, Mapping, Optional
def should_emit_session_stall_notification(
*,
timeout_seconds: float,
idle_seconds: Optional[float],
has_pending_inbound: bool,
already_notified: bool,
) -> bool:
"""Return True when a stall warning should be sent for this session."""
if timeout_seconds <= 0:
return False
if not has_pending_inbound:
return False
if already_notified:
return False
if idle_seconds is None:
return False
return idle_seconds >= timeout_seconds
def should_clear_session_stall_notification(
*,
timeout_seconds: float,
idle_seconds: Optional[float],
has_pending_inbound: bool,
) -> bool:
"""Return True when a prior stall notice may be cleared (episode ended)."""
if not has_pending_inbound:
return True
if timeout_seconds <= 0:
return True
# Unknown progress: hold the latch. Do not treat observation gaps as recovery.
if idle_seconds is None:
return False
return idle_seconds < timeout_seconds
def format_session_stall_notification(idle_seconds: float) -> str:
"""User-facing stall warning (ASCII minutes; matches issue #72016 copy)."""
mins = max(1, int(idle_seconds // 60))
return (
f"⚠️ Agent session appears stalled (last activity {mins} min ago). "
f"Try /new to reset."
)
def resolve_session_idle_seconds_from_activity(
activity: Optional[Mapping[str, Any]],
*,
now: Optional[float] = None,
) -> Optional[float]:
"""Idle seconds from a shared activity snapshot only (#72039 contract).
Prefers ``seconds_since_activity`` when present and finite; otherwise
derives from ``last_activity_at`` / ``last_activity_ts``. Returns
``None`` when there is no usable progress timestamp — callers must
not fall back to turn-start or pending-inbound clocks.
"""
if not activity:
return None
elapsed = activity.get("seconds_since_activity")
if isinstance(elapsed, bool):
elapsed = None
if elapsed is not None:
try:
idle = float(elapsed)
except (TypeError, ValueError):
idle = None
else:
if math.isfinite(idle):
if idle < 0:
return 0.0
return idle
# Non-finite: fall through to last_activity_at / last_activity_ts
ts = activity.get("last_activity_at")
if ts is None:
ts = activity.get("last_activity_ts")
if ts is None:
return None
if isinstance(ts, bool):
return None
try:
when = float(ts)
except (TypeError, ValueError):
return None
if not math.isfinite(when):
return None
if now is None:
import time as _time
clock = float(_time.time())
else:
clock = float(now)
idle = clock - when
if idle < 0:
return 0.0
return idle
+475
View File
@@ -0,0 +1,475 @@
"""Per-session gateway state consolidated into one container.
GatewayRunner historically carried ~19 separate ``Dict[str, ...]`` attributes
keyed by session_key, each with its own ad-hoc lifecycle. Three failure
classes grew out of that shape:
1. Boundary drift — every conversation boundary carried a hand-copied
pop-list that went stale when a new dict was added (#48031, #58403,
#10702, #35809). Mitigated by the ``_CONVERSATION_SCOPED_STATE`` registry,
now structurally fixed: the fields live in one ``ConversationState``
dataclass with a single ``clear()``.
2. Turn-release drift — ad-hoc ``del self._running_agents[key]`` sites that
popped different subsets of the turn dicts. Mitigated by
``_release_running_agent_state``, now ``TurnState.clear()``.
3. Wholesale-reset races — lazy-init paths like
``self._session_reasoning_overrides = {}`` replaced the ENTIRE dict,
discarding concurrent sessions' entries when raced. Structurally
impossible now: state is per-session, resets touch one field of one
``SessionState``.
Scopes (placement follows where each dict is CLEARED today):
- ``SessionState.turn`` — reset at end of every running turn.
- ``SessionState.conversation`` — reset at conversation boundaries
(/new, /resume, auto-reset, expiry, compression-exhausted reset).
- ``SessionState.persistent`` — own lifecycles (approval resolution, update
prompt answer, native-image consumption); ``run_generation`` is monotonic
and NEVER reset (#28686).
Entries in ``GatewayRunner._sessions`` are never evicted (matching the old
dicts, most of which also leaked empty/stale entries for dead sessions —
see the migration table in the consolidating commit). Follow-up work may
add eviction of fully-default SessionStates.
"""
from __future__ import annotations
from collections.abc import MutableMapping
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
# Presence-sensitive sentinel: /fast stores "priority" or None (explicit
# normal), so key PRESENCE — not value truthiness — decides whether the
# override applies. ``_UNSET_TIER`` means "no override recorded".
_UNSET_TIER = object()
# Public alias for callers outside this module.
SERVICE_TIER_UNSET = _UNSET_TIER
@dataclass
class TurnState:
"""State scoped to one running gateway turn.
Cleared by ``GatewayRunner._release_running_agent_state`` (via
``clear()``) at every site that ends a running turn. ``lease_token`` /
``lease_generation`` are deliberately NOT cleared here — they are owned
by ``_release_turn_lease`` (#64934), which must release the registry
lease exactly once per acquiring turn.
"""
# Running AIAgent instance (or _AGENT_PENDING_SENTINEL); None = idle.
agent: Any = None
# Turn start timestamp (0.0 = not running).
started_ts: float = 0.0
# Cross-process active-session slot lease (None = none held).
lease: Any = None
# Last busy-ack timestamp (debounce; 0.0 = never acked).
busy_ack_ts: float = 0.0
# Held turn-lease token + the run generation that acquired it. The old
# ``_turn_lease_tokens`` dict was keyed by (session_key, generation) so a
# stale unwind could never free a newer turn's lease; the pair encoding
# preserves that: release/rebind only match when generation is current.
lease_token: Any = None
lease_generation: Optional[int] = None
def clear(self) -> None:
"""Reset the per-turn slot (agent / start ts / lease / busy-ack).
Mirrors the exact clear set of the old _release_running_agent_state:
_running_agents, _running_agents_ts, _active_session_leases (popped
by the caller so it can call ``lease.release()``), _busy_ack_ts.
"""
self.agent = None
self.started_ts = 0.0
self.lease = None
self.busy_ack_ts = 0.0
@dataclass
class ConversationState:
"""State scoped to one conversation (survives turns, not boundaries)."""
# /model per-session override (model/provider/api_key/base_url/api_mode).
model_override: Optional[Dict[str, Any]] = None
# /model --once restore snapshot.
one_turn_restore: Optional[Dict[str, Any]] = None
# /reasoning per-session override.
reasoning_override: Optional[Dict[str, Any]] = None
# /fast per-session override: "priority" or None; _UNSET_TIER = absent.
service_tier_override: Any = _UNSET_TIER
# Last successfully-resolved non-empty model (#35314 recovery).
last_resolved_model: str = ""
# /queue overflow FIFO (adapter slot holds the head).
queued_events: List[Any] = field(default_factory=list)
# Per-turn must-deliver sidecar notes (one-shot).
sidecar_notes: List[str] = field(default_factory=list)
# Pinned session-context bytes: (change_key, text).
ephemeral_pin: Optional[Tuple[Any, ...]] = None
# Last voice-channel context delivered (None = never delivered).
vc_last: Optional[str] = None
def clear(self) -> None:
"""Reset every conversation-scoped field to its default.
The structural successor of the ``_CONVERSATION_SCOPED_STATE``
pop-loop: adding a field here means every boundary clears it
automatically.
"""
self.model_override = None
self.one_turn_restore = None
self.reasoning_override = None
self.service_tier_override = _UNSET_TIER
self.last_resolved_model = ""
self.queued_events = []
self.sidecar_notes = []
self.ephemeral_pin = None
self.vc_last = None
@dataclass
class PersistentState:
"""State with its own lifecycle — NOT cleared by turn or boundary resets
wholesale (approvals/update prompts ARE cleared by the boundary
*security* funnel, but individually, matching the old behavior)."""
# Pending exec approval ({"command": ..., "pattern_key": ...}).
approvals: Optional[Dict[str, Any]] = None
# /update prompt awaiting a user response.
update_prompt_pending: bool = False
# Image paths staged for native (inline) attachment; consumed one-shot.
native_image_paths: List[str] = field(default_factory=list)
# Legacy runner-level pending message text (write-mostly; flushed to
# disk on shutdown — see #72680). NOTE: distinct from the adapter-level
# ``_pending_messages`` (Dict[str, MessageEvent]) in gateway/base.py,
# which is a different store that happens to share the old name.
pending_command_text: Optional[str] = None
# Monotonic run-generation counter (#28686). NEVER reset: clearing it
# would break stale-run detection.
run_generation: int = 0
# Consecutive session-hygiene compression failures for this session
# (#79624). The in-agent compressor escalates repeat timeouts via
# ContextCompressor._consecutive_timeout_failures, but hygiene builds a
# FRESH AIAgent per run and bind_session_state() zeroes that counter, so
# the in-agent ladder is structurally unreachable from the gateway.
# Tracking the streak here — outside the per-run agent — lets hygiene
# escalate its cooldown instead of retrying on a flat interval forever.
# Reset on a successful compression, not by turn/boundary resets.
#
# PROCESS-LOCAL, deliberately: `PersistentState` means "survives turn and
# boundary resets", NOT "survives a restart" — this field has no disk flush
# (unlike `pending_command_text` above, #72680), so a gateway restart drops
# escalation back to rung 1 while the DB-backed deadline itself survives
# (#74136). Keying on `session_key` rather than `session_id` is what buys
# correctness across compaction ROTATION (the sid changes, the chat does
# not). gateway.run mirrors this value to the DB keyed by session_key so
# the same semantics also survive gateway restarts.
hygiene_failure_streak: int = 0
@dataclass
class SessionState:
"""All per-session gateway state, grouped by lifecycle scope."""
turn: TurnState = field(default_factory=TurnState)
conversation: ConversationState = field(default_factory=ConversationState)
persistent: PersistentState = field(default_factory=PersistentState)
# ---------------------------------------------------------------------------
# Legacy dict-view adapters.
#
# Dozens of tests construct bare runners (object.__new__) and read/write the
# old dict attributes directly (``runner._running_agents = {}``,
# ``assert key in runner._pending_approvals``...). These views keep that
# surface working: each is a LIVE MutableMapping over one SessionState field
# across all sessions. Production code in gateway/run.py accesses state via
# ``self._session_state(key).<scope>.<field>`` — the views exist for tests
# and for the thin remaining mixin/adapter call sites documented in the
# consolidating commit.
# ---------------------------------------------------------------------------
class _FieldSpec:
"""Describes one legacy dict: scope attr, field name, absence test."""
__slots__ = ("scope", "name", "default", "is_present")
def __init__(
self,
scope: str,
name: str,
default: Callable[[], Any],
is_present: Callable[[Any], bool],
) -> None:
self.scope = scope
self.name = name
self.default = default
self.is_present = is_present
def _present_not_none(v: Any) -> bool:
return v is not None
def _present_nonzero(v: Any) -> bool:
return bool(v)
def _present_not_unset(v: Any) -> bool:
return v is not _UNSET_TIER
class SessionFieldView(MutableMapping):
"""Live dict-like view of one SessionState field across sessions."""
__slots__ = ("_runner", "_spec")
def __init__(self, runner: Any, spec: _FieldSpec) -> None:
self._runner = runner
self._spec = spec
# -- internals --------------------------------------------------------
def _sessions(self) -> Dict[str, SessionState]:
return self._runner.__dict__.get("_sessions") or {}
def _value(self, state: SessionState) -> Any:
return getattr(getattr(state, self._spec.scope), self._spec.name)
# -- mapping protocol ---------------------------------------------------
def __getitem__(self, key: str) -> Any:
state = self._sessions().get(key)
if state is None:
raise KeyError(key)
value = self._value(state)
if not self._spec.is_present(value):
raise KeyError(key)
return value
def __setitem__(self, key: str, value: Any) -> None:
state = self._runner._session_state(key)
setattr(getattr(state, self._spec.scope), self._spec.name, value)
def __delitem__(self, key: str) -> None:
state = self._sessions().get(key)
if state is None or not self._spec.is_present(self._value(state)):
raise KeyError(key)
setattr(
getattr(state, self._spec.scope),
self._spec.name,
self._spec.default(),
)
def __iter__(self) -> Iterator[str]:
for key, state in list(self._sessions().items()):
if self._spec.is_present(self._value(state)):
yield key
def __len__(self) -> int:
return sum(1 for _ in self)
def __contains__(self, key: object) -> bool:
state = self._sessions().get(key) # type: ignore[arg-type]
return state is not None and self._spec.is_present(self._value(state))
def clear(self) -> None: # avoid MutableMapping's popitem loop
for state in list(self._sessions().values()):
setattr(
getattr(state, self._spec.scope),
self._spec.name,
self._spec.default(),
)
# Mapping doesn't provide __eq__; tests compare against plain dicts.
def __eq__(self, other: object) -> bool:
if isinstance(other, (dict, MutableMapping)):
return dict(self.items()) == dict(other)
return NotImplemented
def __ne__(self, other: object) -> bool:
result = self.__eq__(other)
return NotImplemented if result is NotImplemented else not result
def __repr__(self) -> str: # pragma: no cover - debug aid
return f"SessionFieldView({self._spec.scope}.{self._spec.name}, {dict(self.items())!r})"
class TurnLeaseTokenView(MutableMapping):
"""Legacy view of ``_turn_lease_tokens``: keyed by (session_key, generation).
The pair now lives on ``TurnState.lease_token`` / ``lease_generation``;
at most one held token exists per session key (the lease registry
serializes acquisition per session), so the single slot is equivalent to
the old tuple-keyed dict.
"""
__slots__ = ("_runner",)
def __init__(self, runner: Any) -> None:
self._runner = runner
def _sessions(self) -> Dict[str, SessionState]:
return self._runner.__dict__.get("_sessions") or {}
@staticmethod
def _split(key: Any) -> Tuple[str, int]:
if not isinstance(key, tuple) or len(key) != 2:
raise KeyError(key)
return key[0], key[1]
def __getitem__(self, key: Any) -> Any:
session_key, generation = self._split(key)
state = self._sessions().get(session_key)
if (
state is None
or state.turn.lease_token is None
or state.turn.lease_generation != generation
):
raise KeyError(key)
return state.turn.lease_token
def __setitem__(self, key: Any, value: Any) -> None:
session_key, generation = self._split(key)
state = self._runner._session_state(session_key)
state.turn.lease_token = value
state.turn.lease_generation = generation
def __delitem__(self, key: Any) -> None:
session_key, generation = self._split(key)
state = self._sessions().get(session_key)
if (
state is None
or state.turn.lease_token is None
or state.turn.lease_generation != generation
):
raise KeyError(key)
state.turn.lease_token = None
state.turn.lease_generation = None
def __iter__(self) -> Iterator[Tuple[str, Any]]:
for key, state in list(self._sessions().items()):
if state.turn.lease_token is not None:
yield (key, state.turn.lease_generation)
def __len__(self) -> int:
return sum(1 for _ in self)
def __eq__(self, other: object) -> bool:
if isinstance(other, (dict, MutableMapping)):
return dict(self.items()) == dict(other)
return NotImplemented
def __ne__(self, other: object) -> bool:
result = self.__eq__(other)
return NotImplemented if result is NotImplemented else not result
# One spec per legacy dict attribute (see the consolidating commit's
# migration table for declared-line / clear-path provenance).
LEGACY_FIELD_SPECS: Dict[str, _FieldSpec] = {
"_running_agents": _FieldSpec("turn", "agent", lambda: None, _present_not_none),
"_running_agents_ts": _FieldSpec("turn", "started_ts", float, _present_nonzero),
"_active_session_leases": _FieldSpec("turn", "lease", lambda: None, _present_not_none),
"_busy_ack_ts": _FieldSpec("turn", "busy_ack_ts", float, _present_nonzero),
"_session_model_overrides": _FieldSpec(
"conversation", "model_override", lambda: None, _present_not_none
),
"_pending_one_turn_model_restores": _FieldSpec(
"conversation", "one_turn_restore", lambda: None, _present_not_none
),
"_session_reasoning_overrides": _FieldSpec(
"conversation", "reasoning_override", lambda: None, _present_not_none
),
"_session_service_tier_overrides": _FieldSpec(
"conversation", "service_tier_override", lambda: _UNSET_TIER, _present_not_unset
),
"_last_resolved_model": _FieldSpec(
"conversation", "last_resolved_model", str, _present_nonzero
),
"_queued_events": _FieldSpec("conversation", "queued_events", list, _present_nonzero),
"_pending_turn_sidecar_notes": _FieldSpec(
"conversation", "sidecar_notes", list, _present_nonzero
),
"_session_ephemeral_pin": _FieldSpec(
"conversation", "ephemeral_pin", lambda: None, _present_not_none
),
"_session_vc_last": _FieldSpec(
"conversation", "vc_last", lambda: None, _present_not_none
),
"_pending_approvals": _FieldSpec(
"persistent", "approvals", lambda: None, _present_not_none
),
"_update_prompt_pending": _FieldSpec(
"persistent", "update_prompt_pending", bool, _present_nonzero
),
"_pending_native_image_paths_by_session": _FieldSpec(
"persistent", "native_image_paths", list, _present_nonzero
),
"_pending_messages": _FieldSpec(
"persistent", "pending_command_text", lambda: None, _present_not_none
),
"_session_run_generation": _FieldSpec(
"persistent", "run_generation", int, _present_nonzero
),
}
def legacy_dict_property(attr_name: str) -> property:
"""Build a legacy dict-shaped @property for one migrated attribute.
Getter returns a live :class:`SessionFieldView`; setter accepts a plain
dict (the ubiquitous test pattern ``runner._X = {...}``), resetting the
field on every known session and then applying the given entries.
"""
spec = LEGACY_FIELD_SPECS[attr_name]
def fget(self: Any) -> SessionFieldView:
return SessionFieldView(self, spec)
def fset(self: Any, mapping: Optional[Dict[str, Any]]) -> None:
view = SessionFieldView(self, spec)
view.clear()
for key, value in (mapping or {}).items():
view[key] = value
def fdel(self: Any) -> None:
# ``del runner._busy_ack_ts`` in older tests simulated a runner
# without the attribute; the closest equivalent is "no entries".
SessionFieldView(self, spec).clear()
return property(
fget,
fset,
fdel,
doc=(
f"Legacy dict view over SessionState.{spec.scope}.{spec.name} "
"(kept for tests that access the pre-SessionState attribute)."
),
)
def legacy_lease_token_property() -> property:
"""Legacy (session_key, generation)-keyed view of held turn-lease tokens."""
def fget(self: Any) -> TurnLeaseTokenView:
return TurnLeaseTokenView(self)
def fset(self: Any, mapping: Optional[Dict[Tuple[str, int], Any]]) -> None:
view = TurnLeaseTokenView(self)
for key in list(view):
del view[key]
for key, value in (mapping or {}).items():
view[key] = value
def fdel(self: Any) -> None:
view = TurnLeaseTokenView(self)
for key in list(view):
del view[key]
return property(
fget,
fset,
fdel,
doc="Legacy (session_key, generation)-keyed turn-lease token view.",
)
+530
View File
@@ -0,0 +1,530 @@
"""Flush pending messages and agent transcripts to disk before shutdown to prevent data loss.
When FTS5 index corruption prevents ``INSERT INTO messages``, the gateway
accumulates messages in ``_pending_messages`` (memory-only) and the live
``agent._session_messages`` cannot be flushed via ``_flush_messages_to_session_db``.
On shutdown, ``.clear()`` discards the only surviving copy — permanent user data loss.
This module provides three hooks:
1. ``flush_pending_to_file()`` — called BEFORE ``_pending_messages.clear()``
during shutdown. Serialises any non-empty pending slots to a JSON file
under ``<hermes_home>/pending_messages/``.
2. ``recover_pending_to_db()`` — called AFTER ``runner.start()`` on startup.
Reads flush files, inserts messages into state.db via ``SessionDB.append_message``
(so FTS indexing, session metadata, and display_kind are handled correctly),
then deletes the flush file on success.
3. ``flush_agent_history_to_file()`` — called from ``_finalize_shutdown_agents``
when ``_flush_messages_to_session_db`` raises. Dumps the live
``agent._session_messages`` to the same atomic JSON recovery directory.
See issue #72680 for the full incident report.
"""
from __future__ import annotations
import itertools
import json
import logging
import os
import time
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
def _get_flush_dir():
"""Return the pending-messages flush directory under the active HERMES_HOME."""
from hermes_constants import get_hermes_home
flush_dir = get_hermes_home() / "pending_messages"
flush_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
if os.name == "posix":
os.chmod(flush_dir, 0o700)
return flush_dir
def _fsync_directory(path: Path) -> None:
"""Persist a directory entry on platforms that support directory fsync."""
if os.name != "posix":
return
directory_fd = os.open(path, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
def _write_payload(flush_dir: Path, payload: Dict[str, Any]) -> Path:
"""Atomically write one private, uniquely named recovery payload.
Returns the path of the published payload file.
"""
from utils import atomic_json_write
file_id = uuid.uuid4().hex
final_path = flush_dir / f"pending-{file_id}.json"
atomic_json_write(
final_path,
payload,
mode=0o600,
default=str,
)
try:
_fsync_directory(flush_dir)
except OSError as exc:
# The atomically published file is still the only recovery copy.
# Keep it even if this filesystem cannot persist directory entries.
logger.debug("Failed to fsync pending-message directory: %s", exc)
return final_path
def flush_pending_to_file(
pending: Dict[str, Any],
*,
reason: str = "shutdown",
) -> int:
"""Serialise non-empty ``_pending_messages`` slots to disk.
Parameters
----------
pending:
The adapter or runner ``_pending_messages`` dict. Values may be
``MessageEvent`` objects (adapter) or plain strings (runner).
reason:
Logged context (``shutdown``, ``restart``, etc.).
Returns
-------
int
Number of sessions flushed.
"""
if not pending:
return 0
flush_dir = _get_flush_dir()
ts = int(time.time())
flushed = 0
for session_key, value in list(pending.items()):
if value is None:
continue
try:
serialised = _serialise_value(value)
if serialised is None:
continue
_write_payload(
flush_dir,
{
"session_key": session_key,
"reason": reason,
"ts": ts,
"data": serialised,
},
)
flushed += 1
except Exception as exc:
logger.debug(
"Failed to flush pending message for %s: %s",
session_key, exc,
)
if flushed:
logger.info(
"Flushed %d pending message(s) to %s (reason=%s)",
flushed, flush_dir, reason,
)
return flushed
def flush_overflow_to_file(
overflow_by_session: Dict[str, Any],
*,
reason: str = "shutdown",
) -> int:
"""Serialise the FIFO overflow tails (``queued_events``) to disk.
Sibling of :func:`flush_pending_to_file` for the second half of the
gateway FIFO (#99882): the adapter slot holds the queue head, and the
per-session ``SessionState.conversation.queued_events`` list holds the
tail. Shutdown flushed only the slot, so every follow-up parked in
overflow at restart time vanished with the process. Each overflow
event is written as its own payload in the same shape as a slot flush
so ``recover_pending_to_db`` replays them unchanged; a ``seq`` field
preserves arrival order within a session.
Returns the number of events flushed.
"""
if not overflow_by_session:
return 0
flush_dir = _get_flush_dir()
ts = int(time.time())
flushed = 0
for session_key, events in list(overflow_by_session.items()):
if not session_key or not events:
continue
for seq, value in enumerate(list(events)):
if value is None:
continue
try:
serialised = _serialise_value(value)
if serialised is None:
continue
_write_payload(
flush_dir,
{
"session_key": session_key,
"reason": reason,
"ts": ts,
"seq": seq,
"data": serialised,
},
)
flushed += 1
except Exception as exc:
logger.debug(
"Failed to flush overflow message for %s: %s",
session_key, exc,
)
if flushed:
logger.info(
"Flushed %d queued overflow message(s) to %s (reason=%s)",
flushed, flush_dir, reason,
)
return flushed
# Reason tag for transcript messages dropped by the in-memory pending cap
# during live operation (#78182). These payloads carry the full transcript
# message dict so they can be replayed verbatim once the DB recovers.
TRANSCRIPT_CAP_DROP_REASON = "transcript_cap_drop"
def spool_dropped_transcript_message(
session_id: str,
message: Dict[str, Any],
) -> Optional[Path]:
"""Spool a transcript message evicted by the runtime pending cap.
Uses the same on-disk pending spool as :func:`flush_pending_to_file`
(one atomic JSON payload per message under
``<hermes_home>/pending_messages/``), so a runtime cap rotation no
longer silently discards user data while the process stays up
(#78182).
Returns the written spool path, or ``None`` when spooling failed —
callers must degrade to the previous drop-and-log behaviour.
"""
try:
flush_dir = _get_flush_dir()
return _write_payload(
flush_dir,
{
"session_key": session_id,
"reason": TRANSCRIPT_CAP_DROP_REASON,
"ts": int(time.time()),
"seq": next(_TRANSCRIPT_SPOOL_SEQ),
"data": {
"session_id": session_id,
"message": message,
},
},
)
except Exception as exc:
logger.debug(
"Failed to spool cap-dropped transcript message for %s: %s",
session_id, exc,
)
return None
# Monotonic tiebreaker so same-second spool files replay in drop order.
_TRANSCRIPT_SPOOL_SEQ = itertools.count()
def drain_transcript_spool(session_id: str, replay) -> tuple[int, int]:
"""Replay cap-dropped transcript messages spooled for *session_id*.
``replay(message_dict)`` is invoked for each spooled message in drop
order; the spool file is deleted only after a successful replay. On
the first replay failure the drain stops and remaining files are kept
for the next attempt (the DB is likely still unhealthy).
Returns ``(replayed, remaining)`` — messages replayed and spool files
left behind for a later retry.
"""
try:
flush_dir = _get_flush_dir()
candidates = list(flush_dir.glob("pending-*.json"))
except Exception as exc:
logger.debug("Cannot scan transcript spool: %s", exc)
return 0, 0
entries = []
for path in candidates:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if payload.get("reason") != TRANSCRIPT_CAP_DROP_REASON:
continue
if payload.get("session_key") != session_id:
continue
message = (payload.get("data") or {}).get("message")
if not isinstance(message, dict):
logger.warning(
"Removing structurally invalid transcript spool file %s", path,
)
path.unlink(missing_ok=True)
continue
entries.append(
(payload.get("ts", 0), payload.get("seq", 0), path.name, path, message)
)
replayed = 0
ordered = sorted(entries, key=lambda e: e[:3])
remaining = 0
for idx, (_ts, _seq, _name, path, message) in enumerate(ordered):
try:
replay(message)
except Exception as exc:
logger.warning(
"Replay of spooled transcript message %s for %s failed; "
"keeping spool file for retry: %s",
path, session_id, exc,
)
remaining = len(ordered) - idx
break
path.unlink(missing_ok=True)
replayed += 1
if replayed:
logger.info(
"Replayed %d spooled transcript message(s) for %s after DB recovery",
replayed, session_id,
)
return replayed, remaining
def _serialise_value(value: Any) -> Optional[dict]:
"""Convert a pending message value to a JSON-serialisable dict."""
# MessageEvent objects have a .text attribute and other fields
if hasattr(value, "text"):
result: Dict[str, Any] = {"text": getattr(value, "text", "")}
# Preserve additional fields if present
for attr in ("session_id", "platform", "sender_id", "sender_name",
"reply_to", "media", "raw_event"):
val = getattr(value, attr, None)
if val is not None:
try:
json.dumps(val)
result[attr] = val
except (TypeError, ValueError):
result[attr] = str(val)
return result
# Plain string (runner-level _pending_messages)
if isinstance(value, str):
return {"text": value}
# Dict — try direct serialisation
if isinstance(value, dict):
try:
json.dumps(value)
return value
except (TypeError, ValueError):
return {"text": str(value)}
return {"text": str(value)}
def recover_pending_to_db(
session_db=None,
) -> int:
"""Recover flushed pending messages into state.db via SessionDB.
Reads all ``*.json`` files from the flush directory, inserts messages
using ``SessionDB.append_message`` (so FTS indexing, session metadata
updates, and all required columns are handled correctly), and deletes
the flush file on success.
Parameters
----------
session_db:
An existing ``SessionDB`` instance. If ``None``, a new one is
opened on the default ``state.db`` path.
Returns
-------
int
Number of messages recovered.
"""
flush_dir = _get_flush_dir()
flush_files = sorted(flush_dir.glob("*.json"))
if not flush_files:
return 0
# Use the provided SessionDB or open one on the default path.
own_db = False
if session_db is None:
from hermes_state import get_shared_session_db
session_db = get_shared_session_db()
own_db = True
def _close_owned_db() -> None:
if not own_db:
return
try:
from hermes_state import release_or_close
release_or_close(session_db)
except Exception:
pass
recovered = 0
for path in flush_files:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
# Agent-history snapshots use a different schema (reason +
# messages list) and are meant for manual operator recovery,
# not automatic DB insertion. Skip them silently.
if payload.get("reason") == "shutdown-with-unpersisted-agent-history":
continue
# Cap-dropped transcript payloads carry the full message dict
# keyed by session_id — replay directly (#78182). This handles
# spool files that were never drained before a restart.
if payload.get("reason") == TRANSCRIPT_CAP_DROP_REASON:
data = payload.get("data", {}) or {}
spooled_sid = data.get("session_id", "")
message = data.get("message")
if not spooled_sid or not isinstance(message, dict):
logger.warning(
"Cannot recover structurally invalid transcript spool "
"file %s; preserved for manual inspection",
path,
)
continue
session_db.append_message(
session_id=spooled_sid,
role=message.get("role", "unknown"),
content=message.get("content") or "",
timestamp=message.get("timestamp") or payload.get("ts"),
)
recovered += 1
path.unlink(missing_ok=True)
continue
session_key = payload.get("session_key", "")
data = payload.get("data", {})
text = data.get("text", "")
if not text or not session_key:
logger.warning(
"Cannot recover structurally invalid pending message from %s; "
"the flush file has been preserved",
path,
)
continue
# The session_key is a gateway routing key (e.g.
# "agent:main:telegram:supergroup:..."). We need the actual
# session_id (e.g. "20260728_120000_abc123") to append a
# message row. Try the session_id field from the serialised
# data first; fall back to scanning sessions for a matching
# session_key in the source column.
session_id = data.get("session_id", "")
if not session_id:
# Try to extract from the session_key itself — gateway
# session keys contain the session_id as the last segment
# in some formats, but that's not guaranteed. Log and
# skip if we can't resolve it.
logger.warning(
"Cannot recover pending message for %s: no session_id "
"in flush file and session_key-to-id resolution is not "
"available at this recovery stage. The message text is "
"preserved in %s",
session_key, path,
)
continue
session_db.append_message(
session_id=session_id,
role="user",
content=text,
timestamp=payload.get("ts", int(time.time())),
)
recovered += 1
path.unlink(missing_ok=True)
except BaseException:
# Shutdown cancellation/interrupt must not strand an owned DB.
_close_owned_db()
raise
except Exception as exc:
logger.warning(
"Failed to recover pending message from %s: %s",
path, exc,
)
# Leave the file for next startup retry.
_close_owned_db()
if recovered:
logger.info(
"Recovered %d pending message(s) from shutdown flush", recovered,
)
return recovered
def flush_agent_history_to_file(
session_id: Optional[str],
history: list,
) -> None:
"""Best-effort dump of an agent's in-memory transcript before teardown.
Used when ``_flush_messages_to_session_db`` raises (e.g. FTS/SQLite
index corruption, #72680): the live ``agent._session_messages`` could
not be written to disk, and a plain debug log would lose it permanently
when the process exits. Serialize to an atomic JSON file outside the
broken DB so an operator can salvage the conversation after repairing
state.db.
Failures are swallowed — shutdown must never block on a best-effort
backup.
"""
if not history:
return
try:
flush_dir = _get_flush_dir()
snapshot = []
for _m in history:
try:
snapshot.append(
_m if isinstance(_m, (dict, list, str, int, float, bool, type(None)))
else str(_m)
)
except Exception:
continue
_write_payload(
flush_dir,
{
"reason": "shutdown-with-unpersisted-agent-history",
"issue": "#72680",
"session_id": session_id,
"count": len(snapshot),
"messages": snapshot,
},
)
logger.warning(
"Preserved %d in-memory message(s) for session %s "
"(possible FTS corruption — recover after repairing state.db)",
len(snapshot),
session_id,
)
except Exception as _e:
logger.warning(
"Agent-history shutdown preservation failed for session %s: %s",
session_id, _e,
)
+476
View File
@@ -0,0 +1,476 @@
"""Shutdown forensics — capture context when the gateway receives SIGTERM/SIGINT.
The gateway's ``shutdown_signal_handler`` runs synchronously inside the
asyncio event loop. We can't safely block it for long, but we DO want a
durable record of who/what triggered the shutdown so that "the gateway
keeps dying" incidents can be diagnosed after the fact.
This module exposes :func:`snapshot_shutdown_context`, a fast (<10ms),
non-blocking probe that returns a structured dict the signal handler can
log immediately, plus :func:`spawn_async_diagnostic`, a fire-and-forget
``ps`` walk that runs as a detached subprocess so it can't block teardown
even if /proc is wedged.
Anything that needs to wait (e.g. shelling out to ``ps aux``) belongs in
the async helper, never in the synchronous probe.
"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from gateway.restart import (
DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT,
resolve_systemd_timeout_stop_sec,
)
_SIGNAL_NAME_BY_NUM: Dict[int, str] = {}
for _name in ("SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT", "SIGUSR1", "SIGUSR2"):
_val = getattr(signal, _name, None)
if _val is not None:
_SIGNAL_NAME_BY_NUM[int(_val)] = _name
def _signal_name(sig: Any) -> str:
"""Return a human-readable signal name (or ``str(sig)`` as fallback)."""
if sig is None:
return "UNKNOWN"
try:
sig_int = int(sig)
except (TypeError, ValueError):
return str(sig)
return _SIGNAL_NAME_BY_NUM.get(sig_int, f"signal#{sig_int}")
def _read_proc_field(pid: int, key: str) -> Optional[str]:
"""Read a single field from /proc/<pid>/status. Linux only; None elsewhere."""
try:
with open(f"/proc/{pid}/status", encoding="utf-8") as fh:
for line in fh:
if line.startswith(key + ":"):
return line.split(":", 1)[1].strip()
except (FileNotFoundError, PermissionError, OSError):
pass
return None
def _read_proc_cmdline(pid: int) -> Optional[str]:
"""Read /proc/<pid>/cmdline as a printable string. Linux only; None elsewhere."""
try:
with open(f"/proc/{pid}/cmdline", "rb") as fh:
data = fh.read()
except (FileNotFoundError, PermissionError, OSError):
return None
if not data:
return None
# cmdline uses NUL separators
return data.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip()
def _proc_summary(pid: int) -> Dict[str, Any]:
"""Compact /proc/<pid> snapshot: pid, ppid, state, uid, cmdline.
Best-effort. Missing fields are simply omitted rather than raising.
"""
summary: Dict[str, Any] = {"pid": pid}
if pid <= 0:
return summary
name = _read_proc_field(pid, "Name")
if name is not None:
summary["name"] = name
state = _read_proc_field(pid, "State")
if state is not None:
summary["state"] = state
ppid = _read_proc_field(pid, "PPid")
if ppid is not None:
try:
summary["ppid"] = int(ppid)
except ValueError:
pass
uid = _read_proc_field(pid, "Uid")
if uid is not None:
# "real effective saved fs"
summary["uid"] = uid.split()[0] if uid else uid
cmdline = _read_proc_cmdline(pid)
if cmdline:
# Truncate aggressively — these can be 4KB
summary["cmdline"] = cmdline[:300]
return summary
def snapshot_shutdown_context(received_signal: Any = None) -> Dict[str, Any]:
"""Fast (<10ms) snapshot of who/what is asking us to shut down.
Captures:
* The signal number/name (so SIGINT vs SIGTERM is visible)
* Our own PID/ppid + parent process info from /proc (Linux)
* Whether systemd is our parent (``ppid==1`` or ``INVOCATION_ID`` set)
* Whether takeover/planned-stop markers exist (consumed lazily by the caller)
* /proc/self limits + load average (1-min)
* Wall-clock and monotonic timestamps for cross-correlating later phases
Pure stdlib, never raises, never blocks on subprocesses.
"""
now = time.time()
monotonic = time.monotonic()
pid = os.getpid()
ppid = os.getppid()
ctx: Dict[str, Any] = {
"ts": now,
"ts_monotonic": monotonic,
"signal": _signal_name(received_signal),
"signal_num": int(received_signal) if received_signal is not None else None,
"pid": pid,
"ppid": ppid,
"parent": _proc_summary(ppid),
"self": _proc_summary(pid),
}
# systemd context. If we were started by a systemd unit, INVOCATION_ID
# is set in our env. ppid==1 (init) is also a strong signal that
# systemd reaped+forwarded the SIGTERM.
invocation_id = os.environ.get("INVOCATION_ID")
if invocation_id:
ctx["systemd_invocation_id"] = invocation_id
journal_stream = os.environ.get("JOURNAL_STREAM")
if journal_stream:
ctx["systemd_journal_stream"] = journal_stream
ctx["under_systemd"] = bool(invocation_id) or ppid == 1
# Load average — high load points the finger at "something else
# crushing the box" rather than "external killer".
try:
ctx["loadavg_1m"] = os.getloadavg()[0]
except (OSError, AttributeError):
pass
# /proc/self/status TracerPid: nonzero means a debugger / strace is
# attached. Useful when "phantom SIGKILL" turns out to be a manual
# gdb session.
try:
tracer = _read_proc_field(pid, "TracerPid")
if tracer is not None and tracer != "0":
ctx["tracer_pid"] = int(tracer) if tracer.isdigit() else tracer
ctx["tracer"] = _proc_summary(int(tracer)) if tracer.isdigit() else None
except (TypeError, ValueError):
pass
# Race-detection hint: did somebody recently start a sibling gateway
# with --replace? We can't see the new process directly here, but if
# there's a takeover marker on disk that DOESN'T name us, that's a
# smoking gun for "another --replace instance is killing us".
# Filenames mirror gateway.status (._TAKEOVER_MARKER_FILENAME /
# _PLANNED_STOP_MARKER_FILENAME); we use string literals here so the
# signal-handler path stays import-light.
try:
hermes_home_str = os.environ.get("HERMES_HOME")
if hermes_home_str:
takeover_path = Path(hermes_home_str) / ".gateway-takeover.json"
if takeover_path.exists():
try:
raw = takeover_path.read_text(encoding="utf-8")
ctx["takeover_marker"] = raw[:300]
ctx["takeover_marker_for_self"] = (
f'"target_pid": {pid}' in raw
or f"'target_pid': {pid}" in raw
)
except OSError:
pass
planned_stop_path = Path(hermes_home_str) / ".gateway-planned-stop.json"
if planned_stop_path.exists():
try:
raw = planned_stop_path.read_text(encoding="utf-8")
ctx["planned_stop_marker"] = raw[:300]
except OSError:
pass
except Exception: # noqa: BLE001 — never raise from a signal handler
pass
return ctx
def spawn_async_diagnostic(
log_path: Path,
signal_name: str,
*,
timeout_seconds: float = 5.0,
) -> Optional[int]:
"""Fire-and-forget ``ps``-style snapshot written to ``log_path``.
Runs as a detached subprocess so it can't block the asyncio event loop
or compete with platform teardown. The subprocess uses its own
``timeout`` so a wedged ``ps`` still self-cleans within
``timeout_seconds``.
Returns the subprocess PID on success, ``None`` on failure. Never
raises.
We deliberately avoid ``subprocess.run(["ps", "aux"])`` from inside the
signal handler (the pre-existing pattern): on a busy host with hundreds
of processes, ``ps aux`` can take >2s to walk /proc, during which the
asyncio loop is frozen and adapter teardown can't begin.
"""
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
except OSError:
return None
# Inline shell so we don't have to ship a helper script. bash -c is
# available on every POSIX target we support; on Windows we just skip
# the snapshot (the platform doesn't ship ps anyway).
if sys.platform == "win32":
return None
script = (
f"echo '=== shutdown diagnostic @ {signal_name} ==='; "
"echo '--- date ---'; date -u +%Y-%m-%dT%H:%M:%SZ; "
"echo '--- ps auxf (top 60 by cpu) ---'; "
"ps auxf --sort=-pcpu 2>/dev/null | head -60; "
"echo '--- pstree of self ---'; "
f"pstree -plau {os.getpid()} 2>/dev/null | head -40 || true; "
"echo '--- /proc/loadavg ---'; "
"cat /proc/loadavg 2>/dev/null || true; "
"echo '--- recent dmesg (oom/killed) ---'; "
"dmesg -T 2>/dev/null | tail -20 || journalctl --user -n 20 --no-pager 2>/dev/null | tail -20 || true; "
"echo '=== end ==='"
)
try:
# Open the log file in append mode and let the subprocess inherit.
# We use os.O_APPEND so concurrent diagnostics from rapid signals
# don't trample each other.
fd = os.open(str(log_path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
except OSError:
return None
try:
# Detach from our process group so the subprocess survives even
# if systemd kills our cgroup with KillMode=control-group (which
# would also reap us anyway, but defense in depth). Without
# start_new_session, a SIGKILL on our cgroup takes the diag down
# before it can flush.
proc = subprocess.Popen(
["timeout", f"{timeout_seconds:.0f}", "bash", "-c", script],
stdout=fd,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
start_new_session=True,
close_fds=True,
)
except (FileNotFoundError, OSError):
try:
os.close(fd)
except OSError:
pass
return None
finally:
# Subprocess inherited the fd; we can drop our handle.
try:
os.close(fd)
except OSError:
pass
return proc.pid
def format_context_for_log(ctx: Dict[str, Any]) -> str:
"""Render a shutdown context dict as a single, scannable log line."""
sig = ctx.get("signal", "?")
parent = ctx.get("parent") or {}
parent_cmd = parent.get("cmdline", "(unknown)")
parent_name = parent.get("name") or "?"
parent_pid = parent.get("pid") or "?"
under_systemd = "yes" if ctx.get("under_systemd") else "no"
load = ctx.get("loadavg_1m")
load_str = f"{load:.2f}" if isinstance(load, (int, float)) else "?"
extras: List[str] = []
if ctx.get("takeover_marker") is not None:
for_self = ctx.get("takeover_marker_for_self")
extras.append(
f"takeover_marker_present={'self' if for_self else 'other'}"
)
if ctx.get("planned_stop_marker") is not None:
extras.append("planned_stop_marker_present=yes")
if ctx.get("tracer_pid"):
extras.append(f"tracer_pid={ctx['tracer_pid']}")
extras_str = (" " + " ".join(extras)) if extras else ""
# Parent cmdline is the most useful single signal — log it prominently.
return (
f"signal={sig} "
f"under_systemd={under_systemd} "
f"parent_pid={parent_pid} "
f"parent_name={parent_name} "
f"loadavg_1m={load_str}"
f"{extras_str} "
f"parent_cmdline={parent_cmd!r}"
)
def context_as_json(ctx: Dict[str, Any]) -> str:
"""JSON-serialise a context dict for structured ingestion. Never raises."""
try:
return json.dumps(ctx, default=str, sort_keys=True)
except (TypeError, ValueError):
return "{}"
def check_systemd_timing_alignment(
drain_timeout: float,
cron_drain_timeout: float = DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT,
) -> Optional[Dict[str, Any]]:
"""At startup, sanity-check that systemd's TimeoutStopSec covers stop.
When the gateway is run under a stale systemd unit file (e.g. the user
upgraded hermes-agent but never re-ran ``hermes setup`` to regenerate
the unit), ``TimeoutStopSec`` can be smaller than the full stop budget
(``restart_drain_timeout`` vs ``cron_drain_timeout`` + cleanup reserve,
plus headroom). Result: SIGTERM arrives, the drain starts, and systemd
SIGKILLs the cgroup mid-drain — looks like a phantom kill in the journal
because the journal only logs ``code=killed status=9``.
Returns ``None`` when the alignment is fine OR we can't determine it
(not running under systemd, ``systemctl`` unavailable, etc.). Returns
a dict with ``timeout_stop_sec`` + ``drain_timeout`` + ``mismatch``
bool when we have data to report.
Best-effort. Never raises.
"""
invocation_id = os.environ.get("INVOCATION_ID")
if not invocation_id:
return None # Not running under systemd (or at least not directly)
# Try to identify our unit name and ask systemctl for its config.
unit_name: Optional[str] = None
try:
# /proc/self/cgroup gives us "0::/user.slice/.../hermes-gateway.service"
with open("/proc/self/cgroup", encoding="utf-8") as fh:
for line in fh:
# systemd cgroup line ends with the unit name
if ".service" in line:
parts = line.strip().split("/")
for p in reversed(parts):
if p.endswith(".service"):
unit_name = p
break
if unit_name:
break
except (OSError, FileNotFoundError):
pass
if not unit_name:
return None
# Query systemctl for TimeoutStopUSec. Use --user OR system depending
# on which manager actually owns the unit. Try user first since
# that's the common case for hermes.
timeout_us: Optional[int] = None
for flag in (["--user"], []):
try:
result = subprocess.run(
["systemctl", *flag, "show", unit_name, "--property=TimeoutStopUSec"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=2.0,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
if result.returncode != 0:
continue
# Output: "TimeoutStopUSec=1min 30s" or "TimeoutStopUSec=90000000"
for line in result.stdout.splitlines():
if line.startswith("TimeoutStopUSec="):
value = line.split("=", 1)[1].strip()
# Try numeric microseconds first
if value.isdigit():
timeout_us = int(value)
else:
timeout_us = parse_systemd_duration_to_us(value)
if timeout_us is not None:
break
if timeout_us is not None:
break
if timeout_us is None:
return None
timeout_stop_sec = timeout_us / 1_000_000.0
expected = float(
resolve_systemd_timeout_stop_sec(drain_timeout, cron_drain_timeout)
)
return {
"unit": unit_name,
"timeout_stop_sec": timeout_stop_sec,
"drain_timeout": drain_timeout,
"cron_drain_timeout": cron_drain_timeout,
"expected_min": expected,
"mismatch": timeout_stop_sec < expected,
}
def parse_systemd_duration_to_us(raw: str) -> Optional[int]:
"""Parse 'TimeoutStopUSec=1min 30s' / '90s' style values to microseconds.
systemd accepts a wide grammar; we cover the common cases (s, ms, min,
h) and return None on anything unexpected. Never raises.
Public: also consumed by hermes_cli.gateway's restart-wait sizing.
"""
if not raw:
return None
units = {
"us": 1,
"ms": 1_000,
"s": 1_000_000,
"sec": 1_000_000,
"min": 60_000_000,
"h": 3_600_000_000,
"hr": 3_600_000_000,
}
total_us = 0
token = ""
digits = ""
for ch in raw + " ":
if ch.isdigit() or ch == ".":
if token:
# End previous unit, start new number
multiplier = units.get(token.lower())
if multiplier is None or not digits:
return None
try:
total_us += int(float(digits) * multiplier)
except ValueError:
return None
digits = ""
token = ""
digits += ch
elif ch.isalpha():
token += ch
elif digits and token:
multiplier = units.get(token.lower())
if multiplier is None:
return None
try:
total_us += int(float(digits) * multiplier)
except ValueError:
return None
digits = ""
token = ""
elif digits and not token:
# Bare number = seconds (rare but valid)
try:
total_us += int(float(digits) * 1_000_000)
except ValueError:
return None
digits = ""
return total_us if total_us > 0 else None
# Backward-compat private alias (pre-promotion name).
_parse_systemd_duration_to_us = parse_systemd_duration_to_us
+649
View File
@@ -0,0 +1,649 @@
"""Out-of-loop shutdown and event-loop liveness backstops (#66892, #69089).
When the asyncio loop freezes mid-drain, every asyncio-based recovery path is
structurally unable to fire: the drain deadline, status rewrites, and forensics
all need the same loop that is stuck. launchd/systemd KeepAlive only restarts a
*dead* process, so a wedged-but-alive gateway sits as a zombie until manual
SIGKILL.
This module provides:
1. A plain OS-thread shutdown watchdog armed at ``stop()``. If shutdown has not
completed within ``restart_drain_timeout + grace``, it dumps all-thread
stacks via ``faulthandler`` plus a metadata snapshot, then ``os._exit`` so
the service manager can revive the process.
2. An event-loop heartbeat file at ``<HERMES_HOME>/state/gateway.heartbeat`` so
external supervision can distinguish "process alive" from "loop frozen"
(``gateway_state.json`` alone can't — it only rewrites on transitions/turns).
3. A lifetime thread watchdog that can still diagnose and hard-exit when the
event loop is too frozen to run its own heartbeat or timeout callbacks.
4. A self-rescheduling floor timer that keeps the loop selector's timeout
finite, giving existing async recovery tasks a chance to resume.
"""
from __future__ import annotations
import asyncio
import faulthandler
import json
import logging
import os
import sys
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from gateway.restart import GATEWAY_SERVICE_RESTART_EXIT_CODE
from hermes_constants import get_hermes_home
from utils import atomic_json_write
logger = logging.getLogger(__name__)
# Extra leash beyond ``agent.restart_drain_timeout`` so a slow-but-progressing
# drain is not cut short. Matches the issue #66892 suggested hardening.
DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S = 60.0
DEFAULT_HEARTBEAT_INTERVAL_S = 30.0
DEFAULT_LOOP_FLOOR_TIMER_INTERVAL_S = 5.0
DEFAULT_LOOP_WATCHDOG_INTERVAL_S = 30.0
DEFAULT_LOOP_WATCHDOG_TIMEOUT_S = 10.0
# 3 sustained misses (~90-120s of loop block) escalate. The false-positive
# class that motivated raising this (the watchdog's own on-loop heartbeat
# fsync stalling the loop it monitors) is fixed at the root by the off-loop
# heartbeat write + two-witness probe (#90502), so the default stays tight
# for genuine wedges. Deployments with legitimately slow loops can tune via
# gateway.loop_watchdog_* in config.yaml.
DEFAULT_LOOP_WATCHDOG_MAX_STRIKES = 3
_HEARTBEAT_RELATIVE = ("state", "gateway.heartbeat")
_WATCHDOG_DUMP_RELATIVE = ("logs", "gateway-shutdown-watchdog.log")
class _LoopFloorTimerHandle:
"""Cancelable owner for the currently scheduled selector floor timer."""
def __init__(self, loop: asyncio.AbstractEventLoop, interval: float):
self._loop = loop
self._interval = interval
self._cancelled = False
self._timer: Optional[asyncio.TimerHandle] = None
self._schedule()
def _schedule(self) -> None:
self._timer = self._loop.call_later(self._interval, self._tick)
def _tick(self) -> None:
if not self._cancelled:
self._schedule()
def cancel(self) -> None:
self._cancelled = True
if self._timer is not None:
self._timer.cancel()
class _LoopLivenessWatchdogHandle:
"""Small lifecycle handle for the daemon liveness thread."""
def __init__(self, stop_event: threading.Event, thread: threading.Thread):
self._stop_event = stop_event
self._thread = thread
def stop(self) -> None:
self._stop_event.set()
def join(self, timeout: Optional[float] = None) -> None:
self._thread.join(timeout=timeout)
def is_alive(self) -> bool:
return self._thread.is_alive()
def _arm_loop_floor_timer(
loop: asyncio.AbstractEventLoop,
interval: float = DEFAULT_LOOP_FLOOR_TIMER_INTERVAL_S,
) -> _LoopFloorTimerHandle:
"""Keep at least one timer pending so selector waits remain bounded."""
try:
resolved_interval = float(interval)
if resolved_interval <= 0:
raise ValueError
except (TypeError, ValueError):
resolved_interval = DEFAULT_LOOP_FLOOR_TIMER_INTERVAL_S
return _LoopFloorTimerHandle(loop, resolved_interval)
def start_loop_liveness_watchdog(
loop: asyncio.AbstractEventLoop,
*,
probe_interval: float = DEFAULT_LOOP_WATCHDOG_INTERVAL_S,
probe_timeout: float = DEFAULT_LOOP_WATCHDOG_TIMEOUT_S,
max_strikes: int = DEFAULT_LOOP_WATCHDOG_MAX_STRIKES,
exit_code: int = GATEWAY_SERVICE_RESTART_EXIT_CODE,
) -> Optional[_LoopLivenessWatchdogHandle]:
"""Start an out-of-loop watchdog that hard-exits after missed probes.
The guard is on by default; operators opt out with
``gateway.loop_watchdog: false`` in config.yaml (enforced by the caller,
``GatewayRunner._start_loop_liveness_guards`` — this module stays
config-agnostic so bare-loop tests can drive it directly).
"""
interval = probe_interval
timeout = probe_timeout
strikes_limit = max_strikes
stop_event = threading.Event()
def _wait_for_probe(probe_event: threading.Event) -> Optional[bool]:
deadline = time.monotonic() + timeout
while True:
if stop_event.is_set():
return None
remaining = deadline - time.monotonic()
if remaining <= 0:
return probe_event.is_set()
if probe_event.wait(timeout=min(remaining, 0.05)):
return True
def _watchdog() -> None:
strikes = 0
while not stop_event.wait(timeout=interval):
probe_event = threading.Event()
try:
loop.call_soon_threadsafe(probe_event.set)
except RuntimeError:
# A normally closed loop cannot be probed and no longer needs
# a process-liveness backstop.
return
except Exception:
logger.debug(
"Failed to schedule gateway loop liveness probe", exc_info=True
)
return
responded = _wait_for_probe(probe_event)
if responded is None:
return
if responded:
strikes = 0
continue
if stop_event.is_set():
return
strikes += 1
if strikes < strikes_limit:
continue
if stop_event.is_set():
return
try:
logger.critical(
"Gateway event loop missed %d consecutive liveness probes; "
"dumping all thread stacks and exiting with code %d so the "
"service supervisor can restart it.",
strikes,
exit_code,
)
except Exception:
pass
try:
faulthandler.dump_traceback(all_threads=True)
except Exception:
logger.debug("Loop liveness faulthandler dump failed", exc_info=True)
if stop_event.is_set():
return
# Record the watchdog exit in the lifecycle sentinel so the next
# boot reports "watchdog hard-exit" instead of misclassifying
# this as an unclean SIGKILL/OOM death (NS-608).
try:
from gateway.lifecycle_ledger import mark_exited
mark_exited(exit_code, reason="loop_liveness_watchdog")
except Exception:
pass
os._exit(exit_code)
return
thread = threading.Thread(
target=_watchdog,
daemon=True,
name="gateway-loop-liveness-watchdog",
)
try:
thread.start()
except Exception:
logger.debug("Failed to start gateway loop liveness watchdog", exc_info=True)
return None
return _LoopLivenessWatchdogHandle(stop_event, thread)
def _process_hermes_home() -> Path:
"""HERMES_HOME for process-level identity files (ignore profile overrides)."""
val = os.environ.get("HERMES_HOME", "").strip()
if val:
return Path(val)
return get_hermes_home()
def get_loop_heartbeat_path(home: Optional[Path] = None) -> Path:
"""Return ``<HERMES_HOME>/state/gateway.heartbeat``."""
base = home if home is not None else _process_hermes_home()
return base.joinpath(*_HEARTBEAT_RELATIVE)
def get_loop_tick_socket_path(
home: Optional[Path] = None, pid: Optional[int] = None
) -> Path:
"""Return the loop-scheduling witness socket for ``pid``.
``<HERMES_HOME>/state/gateway.loop-tick.<pid>.sock`` — PID-suffixed so a
leftover node from a previous process can never be mistaken for this
gateway's witness. Served by the gateway loop itself (see
``_tick_socket_handler``): an answer is direct proof that the loop is
dispatching, which is exactly the property the heartbeat file lost when
its write moved off-loop (#90502).
"""
base = home if home is not None else _process_hermes_home()
return base.joinpath(
"state", f"gateway.loop-tick.{int(pid if pid is not None else os.getpid())}.sock"
)
def get_shutdown_watchdog_dump_path(home: Optional[Path] = None) -> Path:
"""Return the faulthandler / metadata dump path for a fired watchdog."""
base = home if home is not None else _process_hermes_home()
return base.joinpath(*_WATCHDOG_DUMP_RELATIVE)
def write_loop_heartbeat(
*,
pid: Optional[int] = None,
start_time: Optional[float] = None,
home: Optional[Path] = None,
extra: Optional[Dict[str, Any]] = None,
) -> Path:
"""Atomically rewrite the loop-liveness heartbeat file.
``start_time`` is the gateway process start (``time.time()`` epoch seconds)
so supervisors can detect PID reuse. Best-effort — never raises.
"""
path = get_loop_heartbeat_path(home)
payload: Dict[str, Any] = {
"pid": int(pid if pid is not None else os.getpid()),
"updated_at": datetime.now(timezone.utc).isoformat(),
"monotonic": time.monotonic(),
}
if start_time is not None:
payload["start_time"] = float(start_time)
# Embed a cheap memory sample (own RSS + MemAvailable + swap) so the
# heartbeat doubles as a rolling pre-death telemetry snapshot: after an
# unclean death (SIGKILL/OOM/VM loss) the last heartbeat is the closest
# surviving record of memory pressure — see gateway.lifecycle_ledger
# (NS-608). Best-effort; <1ms of /proc reads on Linux, {} elsewhere.
try:
from gateway.lifecycle_ledger import sample_memory
mem = sample_memory()
if mem:
payload["mem"] = mem
except Exception:
pass
if extra:
payload.update(extra)
try:
atomic_json_write(path, payload, indent=None)
except Exception:
logger.debug("Failed to write gateway loop heartbeat", exc_info=True)
return path
def resolve_shutdown_watchdog_delay(
drain_timeout: float,
*,
grace_s: float = DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S,
) -> float:
"""Return the wall-clock leash for the shutdown watchdog thread."""
try:
drain = max(float(drain_timeout), 0.0)
except (TypeError, ValueError):
drain = 0.0
try:
grace = max(float(grace_s), 0.0)
except (TypeError, ValueError):
grace = DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S
return drain + grace
def _write_watchdog_dump(
dump_path: Path,
*,
delay_s: float,
snapshot: Optional[Dict[str, Any]],
) -> None:
"""Best-effort faulthandler + metadata dump before hard-exit."""
try:
dump_path.parent.mkdir(parents=True, exist_ok=True)
except OSError:
return
header = {
"event": "shutdown_watchdog_fired",
"pid": os.getpid(),
"delay_s": delay_s,
"fired_at": datetime.now(timezone.utc).isoformat(),
"snapshot": snapshot or {},
}
try:
with open(dump_path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(header, default=str) + "\n")
fh.write("--- faulthandler dump (all threads) ---\n")
fh.flush()
try:
faulthandler.dump_traceback(file=fh, all_threads=True)
except Exception:
fh.write("(faulthandler.dump_traceback failed)\n")
fh.write("--- end dump ---\n")
fh.flush()
except Exception:
pass
# Also dump to stderr so journald/launchd capture it even if the file
# write failed (wedged disk was one of the #66892 hypotheses).
try:
sys.stderr.write(
f"Gateway shutdown watchdog fired after {delay_s:.0f}s "
f"(pid={os.getpid()}); dumping all thread stacks.\n"
)
sys.stderr.flush()
faulthandler.dump_traceback(all_threads=True)
except Exception:
pass
def arm_shutdown_watchdog(
delay_s: float,
*,
done_event: Optional[threading.Event] = None,
snapshot_fn: Optional[Callable[[], Dict[str, Any]]] = None,
exit_code: int = 1,
dump_path: Optional[Path] = None,
name: str = "gateway-shutdown-watchdog",
) -> threading.Event:
"""Arm a daemon-thread hard-exit backstop for a wedged shutdown path.
If ``done_event`` is set before ``delay_s`` elapses, the thread exits
quietly (normal / progressing shutdown completed). Otherwise it dumps
diagnostics and calls ``os._exit(exit_code)``.
Never raises. Returns the ``done_event`` (creating one when omitted) so
the caller can disarm on successful completion.
"""
done = done_event if done_event is not None else threading.Event()
try:
delay = max(float(delay_s), 0.0)
except (TypeError, ValueError):
delay = DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S
if delay <= 0:
return done
def _watchdog() -> None:
# Wait with interruptible chunks so a late disarm doesn't need the
# full remaining sleep to observe done_event.
deadline = time.monotonic() + delay
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
if done.wait(timeout=min(remaining, 1.0)):
return
if done.is_set():
return
snapshot: Optional[Dict[str, Any]] = None
if snapshot_fn is not None:
try:
snapshot = snapshot_fn()
except Exception as exc:
snapshot = {"snapshot_error": repr(exc)}
target = dump_path if dump_path is not None else get_shutdown_watchdog_dump_path()
_write_watchdog_dump(target, delay_s=delay, snapshot=snapshot)
try:
logger.critical(
"Shutdown watchdog fired after %.0fs — forcing process exit "
"(asyncio drain path appears wedged; see %s)",
delay,
target,
)
except Exception:
pass
for stream in (sys.stdout, sys.stderr):
try:
stream.flush()
except Exception:
pass
# Mirror _exit_after_graceful_shutdown: release PID file + runtime
# lock BEFORE the log drain (locks must never be stranded), then
# drain the async log queue so the logger.critical above actually
# reaches the file before os._exit bypasses atexit. (#66892)
try:
from gateway.status import remove_pid_file, release_gateway_runtime_lock
remove_pid_file()
release_gateway_runtime_lock()
except Exception:
pass
try:
from hermes_logging import drain_log_queue
drain_log_queue(timeout=1.0)
except Exception:
pass
# Record the watchdog exit so the next boot's unclean-death detector
# reports "shutdown watchdog fired" instead of SIGKILL/OOM (NS-608).
try:
from gateway.lifecycle_ledger import mark_exited
mark_exited(exit_code, reason="shutdown_watchdog")
except Exception:
pass
os._exit(exit_code)
try:
threading.Thread(target=_watchdog, daemon=True, name=name).start()
except Exception:
logger.debug("Failed to arm shutdown watchdog", exc_info=True)
return done
async def _tick_socket_handler(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
"""Answer a liveness ping with one byte.
Runs on the gateway loop: the reply is produced only while the loop is
actually dispatching, so a successful read is a witness of loop
schedulability that no executor thread and no filesystem stall can
refresh. A UNIX-socket write is a socket-buffer copy — no fsync, no
disk I/O — so the witness keeps working on the exact filesystem that
stalls the heartbeat write. Best-effort; never raises.
"""
try:
writer.write(b"1")
await writer.drain()
except Exception:
pass
finally:
try:
writer.close()
except Exception:
pass
async def loop_heartbeat_forever(
*,
interval_s: float = DEFAULT_HEARTBEAT_INTERVAL_S,
start_time: Optional[float] = None,
home: Optional[Path] = None,
should_continue: Optional[Callable[[], bool]] = None,
) -> None:
"""Rewrite the loop heartbeat file on a cadence until cancelled / gated off.
Runs as an asyncio task on the gateway loop — if the loop freezes, this task
stops and the file mtime/updated_at goes stale for external monitors. That
property is load-bearing and is preserved below: the write is still
*initiated* by the loop, so a frozen loop still lets the file age.
The write itself is handed to a thread, because it is not free. It ends in
``atomic_json_write`` -> ``os.fsync``, and on a filesystem that stalls, that
fsync blocks whatever thread runs it. Doing it inline meant the loop-liveness
watchdog's own heartbeat could block the loop it exists to monitor: the probe
times out at ``DEFAULT_LOOP_WATCHDOG_TIMEOUT_S`` (10s) and gives up after
``DEFAULT_LOOP_WATCHDOG_MAX_STRIKES`` (3), a ~90-120s budget, while a WSL2
VHDX under io pressure was measured stalling a trivial stat-and-fsync probe
at p99 31s and max 112s. So the watchdog killed the loop for being
unresponsive at the moment it was blocked inside the watchdog's own write.
Awaited, not fire-and-forget: an unawaited task would keep the file fresh
while the loop was wedged, which is exactly the signal the docstring above
promises. And a single in-flight write at a time, so a 112s stall cannot pile
up one queued thread per interval behind it.
Because the write is now off-loop, file freshness is no longer *proof* of
loop schedulability: a stalled write or a saturated executor can age the file
while the loop runs, and a write that lands after the loop froze can keep it
fresh. The file therefore stops being sufficient authority on its own. This
task also arms a loop-scheduling witness — a UNIX socket answered by the
loop itself (``_tick_socket_handler``) — and records whether it is armed in
the heartbeat payload (``loop_tick_socket``). External probes must require
the witness to agree with file staleness before classifying a loop as
wedged; see ``hermes_cli.gateway.probe_gateway_loop_liveness`` for the
two-witness contract.
"""
try:
interval = max(float(interval_s), 1.0)
except (TypeError, ValueError):
interval = DEFAULT_HEARTBEAT_INTERVAL_S
# Arm the loop-scheduling witness. Best-effort: a failed bind (permissions,
# path length) must not abort the gateway or the file heartbeat — it only
# disables the witness, and the payload flag tells probes that staleness is
# no longer sufficient authority to escalate.
#
# Windows (non-POSIX generally): asyncio AF_UNIX support is POSIX-only, so
# the AF_UNIX arm below is gated to POSIX — an ungated call raised
# AttributeError on every native-Windows gateway start (#96956). Instead of
# leaving the witness permanently absent there, the non-POSIX arm binds a
# TCP loopback server on 127.0.0.1 with an OS-assigned port and publishes
# the port in the heartbeat payload (``loop_tick_tcp_port``) so probes know
# where to connect. Same protocol, same loop-owned semantics. If that bind
# fails, the payload records loop_tick_socket=False and probes classify
# UNKNOWN, never WEDGED — the graceful-drain backstop stays in place. (WSL2
# — the #90502 incident environment — is Linux and arms the socket.)
tick_server = None
tick_socket_path = None
tick_tcp_port = None
try:
if os.name == "posix":
tick_socket_path = get_loop_tick_socket_path(home)
tick_socket_path.parent.mkdir(parents=True, exist_ok=True)
# Re-bind over a leftover node from a dead process (os._exit(75) /
# SIGKILL skip the finally-unlink; PID reuse re-lands on this
# PID-suffixed path) is handled by asyncio itself:
# create_unix_server os.remove()s an existing socket node before
# binding — guarded by test_producer_rebinds_over_stale_socket_node.
# What asyncio does NOT do is clean up SIBLING nodes from other
# dead PIDs, so sweep those to keep state/ from accumulating
# gateway.loop-tick.*.sock nodes across crash-restart cycles.
# POSIX-only: os.kill(pid, 0) is a liveness probe here, but on
# Windows os.kill calls TerminateProcess for non-CTRL signals —
# and AF_UNIX server nodes are never created there anyway.
try:
for _stale in tick_socket_path.parent.glob(
"gateway.loop-tick.*.sock"
):
if _stale == tick_socket_path:
continue
try:
_stale_pid = int(_stale.name.split(".")[-2])
except (ValueError, IndexError):
_stale.unlink(missing_ok=True)
continue
try:
os.kill(_stale_pid, 0) # windows-footgun: ok — inside os.name == "posix" gate
except OSError:
_stale.unlink(missing_ok=True)
except Exception:
logger.debug(
"stale loop-tick socket sweep failed", exc_info=True
)
tick_server = await asyncio.start_unix_server(
_tick_socket_handler, path=str(tick_socket_path)
)
else:
# Windows / non-POSIX: no AF_UNIX support, so use a TCP loopback
# server on 127.0.0.1 as the loop-scheduling witness instead.
# Same protocol (connect → read one byte "1"), same semantics
# (pure in-memory, zero disk I/O, answered only when the loop
# is dispatching). Port is dynamic (assigned by the OS) and
# published via the heartbeat payload so external probes know
# where to connect.
tick_server = await asyncio.start_server(
_tick_socket_handler, host="127.0.0.1", port=0
)
# Get the actual port assigned by the OS
_sock_addrs = tick_server.sockets if hasattr(tick_server, "sockets") else []
for _s in _sock_addrs:
try:
_sname = _s.getsockname()
if isinstance(_sname, tuple) and len(_sname) >= 2:
tick_tcp_port = int(_sname[1])
break
except Exception:
pass
except Exception:
tick_server = None
tick_tcp_port = None
logger.warning(
"Loop tick socket unavailable — liveness probes will have no "
"loop-scheduling witness and will not escalate on a stale heartbeat",
exc_info=True,
)
async def _write_off_loop() -> None:
# write_loop_heartbeat never raises, so a failure here is an executor
# problem (shutdown, saturation) and must not kill the heartbeat task.
try:
await asyncio.to_thread(
write_loop_heartbeat,
start_time=start_time,
home=home,
extra={
"loop_tick_socket": tick_server is not None,
"loop_tick_tcp_port": tick_tcp_port,
},
)
except asyncio.CancelledError:
raise
except Exception:
logger.debug("Loop heartbeat write failed off-loop", exc_info=True)
try:
# Immediate first write so monitors see a fresh file as soon as the
# gateway is running, not after the first interval.
await _write_off_loop()
while True:
if should_continue is not None and not should_continue():
return
await asyncio.sleep(interval)
if should_continue is not None and not should_continue():
return
await _write_off_loop()
finally:
if tick_server is not None:
tick_server.close()
try:
await tick_server.wait_closed()
except Exception:
pass
if tick_socket_path is not None:
try:
tick_socket_path.unlink(missing_ok=True)
except Exception:
pass
+229
View File
@@ -0,0 +1,229 @@
"""Per-platform slash command access control.
This module sits beside the existing per-platform allowlist (``allow_from``)
and adds a second axis: of the users who are *allowed to talk to the
gateway*, which ones can run *which slash commands*.
Two lists per platform scope (DM vs group, mirroring ``allow_from`` vs
``group_allow_from``):
- ``allow_admin_from`` — user IDs that get every registered slash
command (built-in + plugin-registered).
- ``user_allowed_commands`` — slash command names non-admin users may
run. Empty / unset → non-admins get no
slash commands.
Backward compatibility:
If ``allow_admin_from`` is not set for a scope, slash command gating
is disabled entirely for that scope. Every allowed user can run every
slash command, exactly like before. This means existing installs are
unaffected until an operator opts in by listing at least one admin.
The gate is applied at the slash command dispatch site in
``gateway/run.py`` so it covers BOTH built-in and plugin-registered
commands via the live registry. Gating slash commands does not affect
plain chat — non-admin users can still talk to the agent normally,
they just can't trigger commands outside ``user_allowed_commands``.
Authored as a slimmed-down salvage of PR #4443's permission tiers
(co-authored by @ReqX). The full tier system, audit log, usage
tracking, rate limiting, and tool filtering from that PR are not
included here — only the slash-command access split.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, FrozenSet, Iterable, Optional, Tuple
# Slash commands that MUST stay reachable for any allowed user, even when
# slash gating is enabled and the user has no commands listed. Without this
# carve-out, a non-admin user has no way to discover what they can or
# can't do (``/help``, ``/whoami``) and no way to see what state the agent
# is in (``/status``). These mirror the smallest set of read-only commands
# we'd hand to a guest. Operators can still narrow this further by writing
# their own ``user_allowed_commands`` (this set is only the implicit
# fallback floor — anything in ``user_allowed_commands`` overrides it
# additively, never restrictively).
_ALWAYS_ALLOWED_FOR_USERS: FrozenSet[str] = frozenset({
"help",
"whoami",
})
@dataclass(frozen=True)
class SlashAccessPolicy:
"""Resolved access policy for a single (platform, scope) pair.
``scope`` is ``"dm"`` for direct messages and ``"group"`` for groups,
channels, threads, and any other multi-user context. The mapping from
SessionSource.chat_type → scope happens in ``policy_for_source``.
"""
enabled: bool # gating active for this scope?
admin_user_ids: FrozenSet[str]
user_allowed_commands: FrozenSet[str]
def is_admin(self, user_id: Optional[str]) -> bool:
if not self.enabled:
# Gating disabled → treat every allowed user as admin so
# downstream code can keep using ``is_admin`` / ``can_run``
# uniformly.
return True
if not user_id:
return False
return str(user_id) in self.admin_user_ids
def can_run(self, user_id: Optional[str], canonical_cmd: str) -> bool:
if not self.enabled:
return True
if self.is_admin(user_id):
return True
if not canonical_cmd:
return False
if canonical_cmd in _ALWAYS_ALLOWED_FOR_USERS:
return True
return canonical_cmd in self.user_allowed_commands
_DM_CHAT_TYPES = frozenset({"dm", "direct", "private", ""})
def _coerce_id_list(raw: Any) -> FrozenSet[str]:
"""Normalize a YAML-loaded admin/user list into a frozenset of strings.
Accepts ``None``, list, tuple, or comma-separated string. Stringifies
each entry and strips whitespace; empty entries are dropped.
"""
if raw is None:
return frozenset()
if isinstance(raw, (list, tuple, set, frozenset)):
items: Iterable[Any] = raw
elif isinstance(raw, str):
items = (s for s in raw.split(",") if s.strip())
else:
# single scalar (int user id, etc.)
items = (raw,)
out: list[str] = []
for it in items:
s = str(it).strip()
if s:
out.append(s)
return frozenset(out)
def _coerce_command_list(raw: Any) -> FrozenSet[str]:
"""Normalize a slash command allowlist.
Strips leading slashes so YAML can read either ``["help", "status"]``
or ``["/help", "/status"]``. Lowercase canonicalization matches how
``resolve_command()`` stores names.
"""
if raw is None:
return frozenset()
if isinstance(raw, (list, tuple, set, frozenset)):
items: Iterable[Any] = raw
elif isinstance(raw, str):
items = (s for s in raw.split(",") if s.strip())
else:
items = (raw,)
out: list[str] = []
for it in items:
s = str(it).strip().lstrip("/").lower()
if s:
out.append(s)
return frozenset(out)
def _scope_for_chat_type(chat_type: Optional[str]) -> str:
if chat_type and chat_type.lower() in _DM_CHAT_TYPES:
return "dm"
return "group"
def _platform_extra(platform_config: Any) -> dict:
"""Return the ``extra`` dict from a PlatformConfig-like object.
Defensively handles None and non-PlatformConfig shapes so calling
code can stay simple.
"""
if platform_config is None:
return {}
extra = getattr(platform_config, "extra", None)
if isinstance(extra, dict):
return extra
if isinstance(platform_config, dict):
# Some test harnesses pass dicts directly.
return platform_config
return {}
def _keys_for_scope(scope: str) -> Tuple[str, str]:
"""Return (admin_key, user_cmd_key) names for a scope."""
if scope == "group":
return ("group_allow_admin_from", "group_user_allowed_commands")
return ("allow_admin_from", "user_allowed_commands")
def policy_from_extra(extra: dict, scope: str) -> SlashAccessPolicy:
"""Build a policy from a platform's ``extra`` dict for one scope.
DM scope falls back to group scope keys ONLY for ``user_allowed_commands``
when the DM scope didn't specify its own. This keeps the common case
(operator wants the same command set DM and group) ergonomic without
forcing duplication. Admin lists are NOT cross-scope: an admin in
DMs is not implicitly an admin in a group.
"""
admin_key, cmd_key = _keys_for_scope(scope)
admin_ids = _coerce_id_list(extra.get(admin_key))
cmds = _coerce_command_list(extra.get(cmd_key))
if scope == "dm" and not cmds:
# DM didn't specify — let group's user_allowed_commands fall through
# so operators only need to list it once if it's the same.
cmds = _coerce_command_list(extra.get("group_user_allowed_commands"))
enabled = bool(admin_ids)
return SlashAccessPolicy(
enabled=enabled,
admin_user_ids=admin_ids,
user_allowed_commands=cmds,
)
def policy_for_source(gateway_config: Any, source: Any) -> SlashAccessPolicy:
"""Resolve the access policy for a SessionSource.
Returns a "disabled" policy (gating off, allow everything) when:
- gateway_config is None
- the platform has no PlatformConfig
- the platform's PlatformConfig has no admin list set for the scope
Callers should treat the returned policy as authoritative for slash
command gating only. It does not gate plain chat messages.
"""
if gateway_config is None or source is None:
return SlashAccessPolicy(
enabled=False,
admin_user_ids=frozenset(),
user_allowed_commands=frozenset(),
)
platforms = getattr(gateway_config, "platforms", None)
platform_config = None
if platforms is not None:
try:
platform_config = platforms.get(source.platform)
except Exception:
platform_config = None
extra = _platform_extra(platform_config)
scope = _scope_for_chat_type(getattr(source, "chat_type", None))
return policy_from_extra(extra, scope)
__all__ = [
"SlashAccessPolicy",
"policy_from_extra",
"policy_for_source",
]
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
"""Compatibility shim — the real implementation is ``hermes_startup_watchdog``.
The startup-liveness watchdog (OOF-298) must be armable *before* the
``gateway`` package is imported: ``gateway/__init__`` eagerly pulls in the
config/session/delivery graph, and an import-time deadlock is squarely inside
the watchdog's coverage mandate. The implementation therefore lives at the
repository top level as a stdlib-only module.
This shim keeps the intuitive ``gateway.startup_watchdog`` import path
working for code that runs after the package is loaded (the disarm site in
``gateway.run``, tests, operators poking at a REPL).
"""
from hermes_startup_watchdog import ( # noqa: F401
DEFAULT_STARTUP_WATCHDOG_TIMEOUT_S,
ENV_STARTUP_WATCHDOG,
ENV_STARTUP_WATCHDOG_TIMEOUT_S,
SERVICE_RESTART_EXIT_CODE,
StartupWatchdogHandle,
arm_startup_watchdog,
disarm_startup_watchdog,
get_startup_watchdog_dump_path,
kick_startup_watchdog,
report_startup_progress,
resolve_startup_watchdog_timeout,
startup_watchdog_disabled,
)
__all__ = [
"DEFAULT_STARTUP_WATCHDOG_TIMEOUT_S",
"ENV_STARTUP_WATCHDOG",
"ENV_STARTUP_WATCHDOG_TIMEOUT_S",
"SERVICE_RESTART_EXIT_CODE",
"StartupWatchdogHandle",
"arm_startup_watchdog",
"disarm_startup_watchdog",
"get_startup_watchdog_dump_path",
"kick_startup_watchdog",
"report_startup_progress",
"resolve_startup_watchdog_timeout",
"startup_watchdog_disabled",
]
Loaded 100 of 11340 files, more files were not shown because too many files have changed in this diff. Show more