Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
"""Built-in gateway hooks that are always registered."""
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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
|
||||
@@ -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,),
|
||||
)
|
||||
@@ -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
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
```
|
||||
@@ -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__)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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))
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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", "")),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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)
|
||||
@@ -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")],
|
||||
},
|
||||
]
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
)
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user