Files
hermes-agent/agent/outbound_webhooks.py
Teknium a9838c2100 fix(multiplex): tool and memory-provider env reads stay inside the routed profile
Under gateway.multiplex_profiles, os.environ holds the DEFAULT profile's .env; a
secondary profile's values exist only in the per-turn secret scope. Every reader
below still read os.environ/os.getenv at call time, so a secondary profile's turn
silently used the default profile's value.

Credentials (F6): FIRECRAWL_API_KEY (read_file hosted OCR), OPENVIKING_API_KEY,
mem0-OSS OPENAI_API_KEY, MODAL_TOKEN_ID/SECRET and BROWSER_USE_API_KEY presence
gates, and the xAI video plugin's os.getenv("XAI_API_KEY") fallback AFTER the
scoped resolver had already missed — the exact fallback-after-miss shape
gateway/AGENTS.md forbids. Deleted, not re-scoped: the resolver is the scope.

Identity / tenant (F7): MEM0_USER_ID/AGENT_ID/HOST/MODE, SUPERMEMORY_CONTAINER_TAG,
RETAINDB_PROJECT, OPENVIKING_ACCOUNT/USER/AGENT (and the whole layered() env
read), HINDSIGHT_BANK_ID/MODE/retain shaping, HERMES_HONCHO_HOST. A raw read
put a secondary profile's memories into the default profile's account/bank/
project/tenant and recalled them back into the default's turns. Each now uses
get_secret with the provider's own per-profile default on a miss.

Endpoints (F8): OPENAI_BASE_URL (aux custom runtime + direct-alias expansion),
XAI_BASE_URL/HERMES_XAI_BASE_URL (aux OAuth), NOUS_INFERENCE_BASE_URL (#65941,
both the aux builder and hermes_cli.auth_nous._nous_inference_env_override),
GATEWAY_PROXY_URL (same UnscopedSecretError-only fallback shape as
GATEWAY_PROXY_KEY three lines below), FIRECRAWL_API_URL, BROWSERBASE_BASE_URL,
SUPERMEMORY/RETAINDB/HONCHO/HINDSIGHT URLs. The keys beside them were already
scoped, so a secondary's key was sent to the default profile's proxy or host.

Targets / display (F11): WEIXIN_HOME_CHANNEL (message posted into the default's
chat), HERMES_LANGUAGE, and agent/i18n's process-wide lru_cache of
display.language — now keyed by HERMES_HOME.

Outbound webhooks: hooks.outbound[].secret_env resolved from os.environ while
the gateway registers each profile's targets inside that profile's scope, so a
secondary's deliveries were signed with the default's secret or left unsigned.

Agent-cache eviction: _spawn_release_thread started a bare threading.Thread, so
commit_memory_session -> provider on_session_end ran with an EMPTY context. The
thread now runs copy_context() and, for the unscoped housekeeping sweep, enters
the owning profile's _profile_runtime_scope resolved from the session key
(agent:<profile>:...). The pressure batch does the same per key.

session_search (#82903): agent/inline_tool_executors.py::_session_search
forwarded every schema argument except `profile`, so a gateway agent could
never select a named profile's store. Forwarded; the ownership-scoping design
in #87779/#87847 is a separate design call and is not attempted here.

Live repro (/tmp/mux_audit/fix-tool-memory-reads/repro.py): 28 FAIL on
origin/main -> 0 FAIL with this change; 10 new invariant tests red on base.

Fixes #82903
Fixes #65941
Fixes #99121
Addresses #87779
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: Michael Versluis (Berry) <michael@wve.nl>
2026-09-11 15:26:46 -07:00

351 lines
15 KiB
Python

"""Outbound webhooks: ``hooks.outbound`` entries (url, events, secret_env|secret, matcher for
pre/post_tool_call, timeout clamped to [1, 60], name) -> notify-only callbacks on the plugin hook
manager, so every ``invoke_hook()`` site can POST lifecycle events (mirror of
``gateway/platforms/webhook.py``). Fire-and-forget through a bounded queue + one daemon worker,
so a target can never block a tool call or influence agent flow. HMAC-SHA256 signed
(``X-Hermes-Signature-256: sha256=<hex>`` over the raw body) when a secret is configured;
``HERMES_SAFE_MODE=1`` skips registration; registration is idempotent.
"""
from __future__ import annotations
import atexit
import hashlib
import hmac
import json
import logging
import queue
import re
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple
from urllib import error as urlerror
from urllib import request as urlrequest
from agent.shell_hooks import (
_TOOL_EVENTS as _TOOL_SCOPED_EVENTS,
_ToolMatcherMixin,
_forget_home_registrations,
_home_key,
_payload_fields,
_utc_now_iso,
)
logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT_SECONDS = 10
MAX_TIMEOUT_SECONDS = 60
MAX_DELIVERY_ATTEMPTS = 2
RETRY_BACKOFF_SECONDS = 1.0
QUEUE_MAX_SIZE = 256
# (home, event, url) triples already wired in this process. Home is part of the key so a
# multiplexed gateway's secondary profiles (own plugin managers) can register identical targets.
_registered: Set[Tuple[str, str, str]] = set()
_registered_lock = threading.Lock()
_delivery_queue: "queue.Queue[Optional[Dict[str, Any]]]" = queue.Queue(maxsize=QUEUE_MAX_SIZE)
_worker_lock = threading.Lock()
_worker: Optional[threading.Thread] = None
@dataclass
class WebhookTarget(_ToolMatcherMixin):
"""Parsed and validated representation of one ``hooks.outbound`` entry."""
_MATCHER_KIND = "outbound webhook"
url: str
events: List[str]
name: str = ""
secret: Optional[str] = None
matcher: Optional[str] = None
timeout: int = DEFAULT_TIMEOUT_SECONDS
compiled_matcher: Optional[re.Pattern] = field(default=None, repr=False)
@property
def label(self) -> str:
return self.name or self.url
def register_from_config(cfg: Optional[Dict[str, Any]]) -> List[WebhookTarget]:
"""Register every configured outbound webhook on the plugin manager. Malformed ``hooks.outbound``
means zero targets — never raises. Returns the targets that ended up wired (deduplicated)."""
if not isinstance(cfg, dict):
return []
from utils import env_var_enabled
if env_var_enabled("HERMES_SAFE_MODE"):
logger.info("HERMES_SAFE_MODE=1 — outbound webhook registration skipped")
return []
targets = iter_configured_targets(cfg)
if not targets:
return []
from hermes_cli.plugins import get_plugin_manager
manager = get_plugin_manager()
home_key = _home_key()
registered: List[WebhookTarget] = []
with _registered_lock:
for target in targets:
wired_any = False
for event in target.events:
key = (home_key, event, target.url)
if key in _registered:
continue
manager._hooks.setdefault(event, []).append(_make_callback(event, target))
_registered.add(key)
wired_any = True
logger.info(
"outbound webhook registered: %s -> %s (matcher=%s, timeout=%ds)",
event, target.label, target.matcher, target.timeout,
)
if wired_any:
registered.append(target)
return registered
def iter_configured_targets(cfg: Optional[Dict[str, Any]]) -> List[WebhookTarget]:
"""Parse ``hooks.outbound`` without registering anything (``hermes hooks list``)."""
if not isinstance(cfg, dict):
return []
hooks_cfg = cfg.get("hooks")
raw = hooks_cfg.get("outbound") if isinstance(hooks_cfg, dict) else None
if raw is None:
return []
if not isinstance(raw, list):
logger.warning("hooks.outbound must be a list of webhook targets; got %s", type(raw).__name__)
return []
return [t for t in (_parse_single_target(i, entry) for i, entry in enumerate(raw)) if t is not None]
def flush(timeout: float = 5.0) -> bool:
"""Block until all queued deliveries are done (or *timeout* elapses); True if drained."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with _delivery_queue.all_tasks_done:
if _delivery_queue.unfinished_tasks == 0:
return True
time.sleep(0.02)
with _delivery_queue.all_tasks_done:
return _delivery_queue.unfinished_tasks == 0
def re_register_config_hooks() -> None:
"""Re-register outbound webhooks after a plugin force-reload cleared ``_hooks``. Only the
current home's idempotence keys are cleared so a force-reload in one profile cannot
invalidate another profile's still-live registration.
Mirrors ``agent.shell_hooks.re_register_config_hooks``: config-owned outbound-webhook callbacks live in
the same ``_hooks`` dict that ``PluginManager.discover_and_load(force=True)`` clears via ``unload()``,
so without this the force-reloaded profile's outbound webhooks go silently inert (#92682 review).
"""
from hermes_cli.config import load_config
_forget_home_registrations(_registered, _registered_lock)
register_from_config(load_config())
def reset_for_tests() -> None:
"""Clear the idempotence set and drain the queue. Test-only helper."""
with _registered_lock:
_registered.clear()
try:
while True:
_delivery_queue.get_nowait()
_delivery_queue.task_done()
except queue.Empty:
pass
def _parse_single_target(index: int, raw: Any) -> Optional[WebhookTarget]:
from hermes_cli.plugins import VALID_HOOKS
def warn(msg: str, *args: Any) -> None:
logger.warning("hooks.outbound[%d]" + msg, index, *args)
if not isinstance(raw, dict):
warn(" must be a mapping with 'url' and 'events' keys; got %s", type(raw).__name__)
return None
url = raw.get("url")
if not isinstance(url, str) or not url.strip():
warn(" is missing a non-empty 'url'")
return None
url = url.strip()
if not url.lower().startswith(("http://", "https://")):
warn(".url must be http(s); got %r — skipped", url)
return None
if url.lower().startswith("http://"):
warn(".url uses plain http:// — payloads (including tool inputs) travel unencrypted. Prefer https.")
events_raw = raw.get("events")
valid_list = ", ".join(sorted(VALID_HOOKS))
if not isinstance(events_raw, list) or not events_raw:
warn(" needs a non-empty 'events' list (valid: %s)", valid_list)
return None
events: List[str] = [ev for ev in events_raw if ev in VALID_HOOKS]
for ev in events_raw:
if ev not in VALID_HOOKS:
warn(": unknown event %r ignored (valid: %s)", ev, valid_list)
if not events:
warn(" has no valid events — skipped")
return None
matcher = raw.get("matcher")
if matcher is not None and not isinstance(matcher, str):
warn(".matcher must be a string regex; ignoring")
matcher = None
if matcher is not None and not any(e in _TOOL_SCOPED_EVENTS for e in events):
warn(".matcher=%r will be ignored — matcher is only honored for pre_tool_call / post_tool_call.", matcher)
matcher = None
timeout_raw = raw.get("timeout", DEFAULT_TIMEOUT_SECONDS)
try:
timeout = int(timeout_raw)
except (TypeError, ValueError):
warn(".timeout must be an int (got %r); using default %ds", timeout_raw, DEFAULT_TIMEOUT_SECONDS)
timeout = DEFAULT_TIMEOUT_SECONDS
name = raw.get("name")
# ``secret_env`` (env var name, preferred) wins over inline ``secret``. Read through the profile
# secret scope: the gateway registers each multiplexed profile's targets inside that profile's
# scope, and a raw environ read would sign a secondary's deliveries with the DEFAULT profile's
# secret (or leave them unsigned when the var lives only in the secondary's .env).
secret_env = raw.get("secret_env")
if isinstance(secret_env, str) and secret_env.strip():
from agent.secret_scope import get_secret
secret = get_secret(secret_env.strip(), "") or None
if secret is None:
warn(".secret_env=%r is not set in the environment — deliveries will be UNSIGNED", secret_env.strip())
else:
secret = raw.get("secret")
secret = secret if isinstance(secret, str) and secret else None
return WebhookTarget(
url=url, events=events, name=name.strip() if isinstance(name, str) else "", secret=secret,
matcher=matcher, timeout=max(1, min(timeout, MAX_TIMEOUT_SECONDS)),
)
def _make_callback(event: str, target: WebhookTarget):
"""Build the notify-only closure ``invoke_hook()`` calls per firing."""
def _callback(**kwargs: Any) -> None:
if event in _TOOL_SCOPED_EVENTS and not target.matches_tool(kwargs.get("tool_name")):
return
delivery_id = uuid.uuid4().hex
try:
body = _serialize_payload(event, kwargs, delivery_id)
except Exception: # a bad payload must not hurt the loop
logger.warning("outbound webhook payload serialization failed (event=%s target=%s)", event, target.label, exc_info=True)
return
_enqueue(_build_delivery(event, target, body, delivery_id))
_callback.__name__ = f"outbound_webhook[{event}:{target.label}]"
_callback.__qualname__ = _callback.__name__
return _callback
def _serialize_payload(event: str, kwargs: Dict[str, Any], delivery_id: str) -> bytes:
"""Render the POST body: shell-hooks stdin shape plus delivery metadata. ``delivery_id``
(also the ``X-Hermes-Delivery`` header) and ``timestamp`` live inside the HMAC-signed
body, so they double as replay protection."""
# Profile resolved at fire time so a multiplexed gateway's receivers can tell which profile emitted.
# See #92674.
from hermes_cli.profiles import get_active_profile_name
payload = {
"hook_event_name": event, "profile": get_active_profile_name(), **_payload_fields(kwargs),
"delivery_id": delivery_id, "timestamp": _utc_now_iso(),
}
return json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
def _build_delivery(event: str, target: WebhookTarget, body: bytes, delivery_id: str) -> Dict[str, Any]:
headers = {
"Content-Type": "application/json", "User-Agent": "Hermes-Agent-Outbound-Webhook",
"X-Hermes-Event": event, "X-Hermes-Delivery": delivery_id,
}
if target.secret:
digest = hmac.new(target.secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
headers["X-Hermes-Signature-256"] = f"sha256={digest}"
return {"url": target.url, "label": target.label, "event": event, "body": body, "headers": headers, "timeout": target.timeout}
def _enqueue(delivery: Dict[str, Any]) -> None:
global _worker
if _worker is None or not _worker.is_alive():
with _worker_lock:
if _worker is None or not _worker.is_alive():
_worker = threading.Thread(target=_worker_loop, name="outbound-webhooks", daemon=True)
_worker.start()
# Daemon worker: a short-lived process could exit right after enqueuing on_session_end.
# Drain at interpreter shutdown, bounded so a dead endpoint can only delay exit, never hang it.
atexit.register(flush, timeout=5.0)
try:
_delivery_queue.put_nowait(delivery)
except queue.Full:
logger.warning(
"outbound webhook queue full (%d pending) — dropping %s event for %s",
QUEUE_MAX_SIZE, delivery["event"], delivery["label"],
)
def _worker_loop() -> None:
while True:
delivery = _delivery_queue.get()
try:
if delivery is not None:
_deliver(delivery)
except Exception: # pragma: no cover — defensive
label = delivery.get("label") if isinstance(delivery, dict) else "?"
logger.warning("outbound webhook delivery crashed (target=%s)", label, exc_info=True)
finally:
_delivery_queue.task_done()
class _NoRedirectHandler(urlrequest.HTTPRedirectHandler):
"""Refuse redirects: urllib would turn a redirected POST into a body-less GET,
silently dropping the signed payload. Any 3xx surfaces as HTTPError instead."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
_opener = urlrequest.build_opener(_NoRedirectHandler)
def _deliver(delivery: Dict[str, Any]) -> None:
"""POST with bounded retries: retry on connection errors and 5xx; 4xx and 3xx are final."""
event, label = delivery["event"], delivery["label"]
last_error = ""
for attempt in range(1, MAX_DELIVERY_ATTEMPTS + 1):
req = urlrequest.Request(delivery["url"], data=delivery["body"], headers=delivery["headers"], method="POST")
try:
with _opener.open(req, timeout=delivery["timeout"]) as resp:
status = getattr(resp, "status", 200)
if 200 <= status < 300:
logger.debug("outbound webhook delivered: %s -> %s (HTTP %d)", event, label, status)
return
last_error = f"HTTP {status}"
except urlerror.HTTPError as exc:
last_error = f"HTTP {exc.code}"
if 300 <= exc.code < 400:
logger.warning(
"outbound webhook target redirected (event=%s target=%s): %s -> %s — redirects are not "
"followed; update the configured url", event, label, last_error, exc.headers.get("Location", "?"),
)
return
if 400 <= exc.code < 500:
logger.warning("outbound webhook rejected (event=%s target=%s): %s — not retrying", event, label, last_error)
return
except Exception as exc:
last_error = str(exc) or type(exc).__name__
if attempt < MAX_DELIVERY_ATTEMPTS:
time.sleep(RETRY_BACKOFF_SECONDS * attempt)
logger.warning(
"outbound webhook delivery failed after %d attempt(s) (event=%s target=%s): %s",
MAX_DELIVERY_ATTEMPTS, event, label, last_error,
)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from pathlib import Path # noqa: F401,E402
from datetime import datetime # noqa: F401,E402
from datetime import timezone # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----