mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
feat(relay): WS-only inbound on the gateway adapter (Phase 3) (#48294)
The connector now delivers inbound (messages + interrupts) over the gateway's OUTBOUND /relay WebSocket, not a signed HTTP POST to an inbound endpoint. The gateway needs no inbound HTTP port — which is what makes hosted gateways (no public IP) able to receive inbound at all. - gateway/relay/adapter.py: connect() wires set_interrupt_inbound_handler( self.on_interrupt) so connector->gateway interrupt_inbound frames bridge into the existing per-session interrupt path (the inbound message handler was already wired). Removed _maybe_start_inbound_receiver() + the _inbound_runner lifecycle — there is no HTTP receiver anymore. - gateway/relay/inbound_receiver.py: deleted (the signed-HTTP InboundDelivery receiver). - gateway/relay/__init__.py: removed relay_inbound_config() (dead with the receiver gone). The delivery key is still set in-process by self-provision for forward-compat but is no longer consumed for inbound. - docs/relay-connector-contract.md: §3 rewritten — inbound is the WS back-channel routed cross-instance via the connector's relay bus; §5 interrupt + §6 auth table updated; the old signed-HTTP-POST + per-tenant-delivery-key-signing path is documented as superseded. gatewayEndpoint noted as passthrough-plane only. Tests: stub_connector grows set_interrupt_inbound_handler + push_interrupt; new test_relay_interrupt case proves connect() wires BOTH inbound handlers and an interrupt_inbound frame over the WS cancels the right session. Removed the HTTP-receiver test; updated the crypto-shedding scan + self-provision delivery-key assertion. 88 relay tests pass. EXPERIMENTAL. Pairs with gateway-gateway (relay bus + WsGatewayDelivery) and the NAS GATEWAY_RELAY_URL stamp. The cross-repo E2E (connector repo) proves the full multi-instance path against this production adapter code.
This commit is contained in:
parent
03d9a95a74
commit
d2c53ff558
9 changed files with 117 additions and 476 deletions
|
|
@ -79,40 +79,6 @@ def relay_connection_auth() -> tuple[Optional[str], Optional[str]]:
|
|||
return (gateway_id or None, secret or None)
|
||||
|
||||
|
||||
def relay_inbound_config() -> tuple[Optional[str], Optional[str], int]:
|
||||
"""Resolve (delivery_key, bind_host, bind_port) for the inbound receiver.
|
||||
|
||||
The connector delivers normalized inbound events to this gateway over a
|
||||
SIGNED HTTP POST (not the outbound WS), verified with the per-tenant delivery
|
||||
key issued at enrollment (``GATEWAY_RELAY_DELIVERY_KEY``). The receiver only
|
||||
starts when a delivery key AND a bind port are configured — a gateway with no
|
||||
public inbound URL (e.g. a purely outbound dev run) simply doesn't run it.
|
||||
|
||||
Env first (Docker), then ``gateway.relay_delivery_key`` /
|
||||
``gateway.relay_inbound_host`` / ``gateway.relay_inbound_port`` in config.yaml.
|
||||
Port 0 (default/unset) -> receiver disabled.
|
||||
"""
|
||||
key = os.environ.get("GATEWAY_RELAY_DELIVERY_KEY", "").strip()
|
||||
host = os.environ.get("GATEWAY_RELAY_INBOUND_HOST", "").strip()
|
||||
port_raw = os.environ.get("GATEWAY_RELAY_INBOUND_PORT", "").strip()
|
||||
if not (key and port_raw):
|
||||
try:
|
||||
from gateway.run import _load_gateway_config # late import to avoid cycle
|
||||
|
||||
cfg = (_load_gateway_config().get("gateway") or {})
|
||||
key = key or str(cfg.get("relay_delivery_key", "") or "").strip()
|
||||
host = host or str(cfg.get("relay_inbound_host", "") or "").strip()
|
||||
if not port_raw:
|
||||
port_raw = str(cfg.get("relay_inbound_port", "") or "").strip()
|
||||
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
|
||||
pass
|
||||
try:
|
||||
port = int(port_raw) if port_raw else 0
|
||||
except ValueError:
|
||||
port = 0
|
||||
return (key or None, host or "0.0.0.0", port)
|
||||
|
||||
|
||||
def relay_endpoint() -> Optional[str]:
|
||||
"""The gateway's own PUBLIC inbound URL, asserted to the connector at provision.
|
||||
|
||||
|
|
@ -318,8 +284,11 @@ def self_provision_if_managed() -> bool:
|
|||
logger.warning("relay self-provision failed (%s); gateway will boot without relay auth", exc)
|
||||
return False
|
||||
|
||||
# Set creds in-process so register_relay_adapter() + relay_inbound_config()
|
||||
# read them from os.environ. Never logged.
|
||||
# Set creds in-process so register_relay_adapter() reads them from os.environ
|
||||
# (the per-gateway secret authenticates the outbound WS upgrade). The delivery
|
||||
# key is still issued by the connector and persisted for forward-compat, but
|
||||
# inbound now rides the WS (no HTTP receiver), so it is not consumed here.
|
||||
# Never logged.
|
||||
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 "")
|
||||
|
|
|
|||
|
|
@ -58,10 +58,6 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
# Capability surface read by stream_consumer (getattr(..., 4096)).
|
||||
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
|
||||
self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain")
|
||||
# Inbound delivery receiver (signed connector→gateway HTTP POSTs). Built
|
||||
# lazily in connect() when a delivery key + bind port are configured; a
|
||||
# purely-outbound dev gateway runs without it. See inbound_receiver.py.
|
||||
self._inbound_runner: Any = None
|
||||
|
||||
# ── capability surface (from descriptor) ─────────────────────────────
|
||||
@property
|
||||
|
|
@ -80,6 +76,12 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
if self._transport is None:
|
||||
raise RuntimeError("RelayAdapter has no transport configured")
|
||||
self._transport.set_inbound_handler(self._on_inbound)
|
||||
# Inbound interrupts (connector -> owning gateway) arrive as
|
||||
# interrupt_inbound frames over the SAME outbound WS; bridge them to the
|
||||
# adapter's interrupt path. WS-only: there is no inbound HTTP receiver.
|
||||
set_interrupt = getattr(self._transport, "set_interrupt_inbound_handler", None)
|
||||
if callable(set_interrupt):
|
||||
set_interrupt(self.on_interrupt)
|
||||
ok = await self._transport.connect()
|
||||
if not ok:
|
||||
return False
|
||||
|
|
@ -92,40 +94,12 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
logger.warning("relay handshake failed: %s", exc)
|
||||
return False
|
||||
self._apply_descriptor(descriptor)
|
||||
# Start the signed inbound-delivery receiver if configured (the connector
|
||||
# POSTs normalized events to it over HTTP, verified with the tenant
|
||||
# delivery key). Non-fatal: a receiver bind failure must not fail the
|
||||
# outbound connection — the gateway can still send.
|
||||
await self._maybe_start_inbound_receiver()
|
||||
# Inbound (messages + interrupts) is delivered over the outbound WS via
|
||||
# the connector's relay bus — there is NO inbound HTTP endpoint (hosted
|
||||
# gateways have no public IP). The transport's reader already dispatches
|
||||
# `inbound` / `interrupt_inbound` frames to the handlers wired above.
|
||||
return True
|
||||
|
||||
async def _maybe_start_inbound_receiver(self) -> None:
|
||||
"""Start the inbound HTTP receiver when a delivery key + port are set."""
|
||||
from gateway.relay import relay_inbound_config
|
||||
|
||||
delivery_key, host, port = relay_inbound_config()
|
||||
if not (delivery_key and port):
|
||||
return # no inbound URL configured -> outbound-only gateway
|
||||
try:
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.relay.inbound_receiver import InboundDeliveryReceiver
|
||||
|
||||
receiver = InboundDeliveryReceiver(
|
||||
delivery_key_verify_list=lambda: [delivery_key],
|
||||
on_message=self._on_inbound,
|
||||
on_interrupt=self.on_interrupt,
|
||||
)
|
||||
runner = web.AppRunner(receiver.build_app(), access_log=None)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, host, port)
|
||||
await site.start()
|
||||
self._inbound_runner = runner
|
||||
logger.info("relay inbound receiver listening on http://%s:%s", host, port)
|
||||
except Exception as exc: # noqa: BLE001 - inbound bind failure must not kill outbound
|
||||
logger.warning("relay inbound receiver failed to start: %s", exc)
|
||||
self._inbound_runner = None
|
||||
|
||||
def _apply_descriptor(self, descriptor: CapabilityDescriptor) -> None:
|
||||
"""Adopt a (re)negotiated descriptor into the live capability surface."""
|
||||
self.descriptor = descriptor
|
||||
|
|
@ -148,12 +122,6 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
await self.interrupt_session_activity(session_key, chat_id)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self._inbound_runner is not None:
|
||||
try:
|
||||
await self._inbound_runner.cleanup()
|
||||
except Exception: # noqa: BLE001 - best-effort teardown
|
||||
pass
|
||||
self._inbound_runner = None
|
||||
if self._transport is not None:
|
||||
await self._transport.disconnect()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
"""Gateway-side inbound delivery receiver. EXPERIMENTAL.
|
||||
|
||||
The connector delivers normalized inbound events to a tenant's gateway over a
|
||||
**signed HTTP POST** (connector ``src/relay/httpGatewayDelivery.ts``), NOT over
|
||||
the gateway's outbound ``/relay`` WebSocket: the connector instance that owns a
|
||||
platform socket is generally not the instance a given gateway dialed out to, so
|
||||
inbound is delivered to a tenant ENDPOINT (which may load-balance across gateway
|
||||
instances). Each delivery is HMAC-signed with the per-tenant **delivery key**
|
||||
(``gateway/relay/auth.py``); this receiver verifies the signature over the EXACT
|
||||
raw request bytes before accepting the event.
|
||||
|
||||
Two routes (mirroring the connector's two POST targets):
|
||||
POST {base} {"type":"message", "event": <MessageEvent>, ...}
|
||||
POST {base}/interrupt {"type":"interrupt","session_key": ..., "reason"?}
|
||||
|
||||
The receiver:
|
||||
1. reads the RAW body bytes (never a reparsed/re-serialized form — the HMAC is
|
||||
over the literal bytes the connector signed),
|
||||
2. verifies ``x-relay-signature`` / ``x-relay-timestamp`` against the delivery
|
||||
key verify list (primary + secondary during rotation), within the replay
|
||||
window — rejects 401 on any failure,
|
||||
3. parses the JSON and dispatches: a ``message`` to the inbound handler (the
|
||||
RelayAdapter's ``handle_message`` via the transport's normal path), an
|
||||
``interrupt`` to the interrupt handler.
|
||||
|
||||
EXPERIMENTAL: the transport protocol may change without a deprecation cycle
|
||||
until ≥2 Class-1 platforms validate it. See docs/relay-connector-contract.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Optional, Sequence
|
||||
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.relay.auth import (
|
||||
DELIVERY_SIG_HEADER,
|
||||
DELIVERY_TS_HEADER,
|
||||
verify_delivery_signature,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Callbacks the receiver dispatches verified deliveries to.
|
||||
InboundMessageHandler = Callable[[MessageEvent], Awaitable[None]]
|
||||
InboundInterruptHandler = Callable[[str, str], Awaitable[None]]
|
||||
|
||||
try: # lazy/optional dep — mirrors the other HTTP-receiving adapters
|
||||
from aiohttp import web
|
||||
except ImportError: # pragma: no cover - exercised only when the extra is absent
|
||||
web = None # type: ignore[assignment]
|
||||
|
||||
AIOHTTP_AVAILABLE = web is not None
|
||||
|
||||
|
||||
def _event_from_wire(raw: dict) -> MessageEvent:
|
||||
"""Rebuild a MessageEvent from the connector's normalized inbound payload.
|
||||
|
||||
Identical mapping to the WS transport's ``_event_from_wire`` (the wire shape
|
||||
is the same; only the transport differs). Kept here so the HTTP receiver has
|
||||
no import dependency on the WS transport module.
|
||||
"""
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
src = raw.get("source", {}) or {}
|
||||
platform = src.get("platform", "relay")
|
||||
try:
|
||||
platform_enum = Platform(platform)
|
||||
except ValueError:
|
||||
platform_enum = Platform.RELAY
|
||||
|
||||
source = SessionSource(
|
||||
platform=platform_enum,
|
||||
chat_id=src.get("chat_id", ""),
|
||||
chat_type=src.get("chat_type", "dm"),
|
||||
chat_name=src.get("chat_name"),
|
||||
user_id=src.get("user_id"),
|
||||
user_name=src.get("user_name"),
|
||||
thread_id=src.get("thread_id"),
|
||||
chat_topic=src.get("chat_topic"),
|
||||
user_id_alt=src.get("user_id_alt"),
|
||||
chat_id_alt=src.get("chat_id_alt"),
|
||||
guild_id=src.get("guild_id"),
|
||||
parent_chat_id=src.get("parent_chat_id"),
|
||||
message_id=src.get("message_id"),
|
||||
)
|
||||
try:
|
||||
msg_type = MessageType(raw.get("message_type", "text"))
|
||||
except ValueError:
|
||||
msg_type = MessageType.TEXT
|
||||
|
||||
return MessageEvent(
|
||||
text=raw.get("text", ""),
|
||||
message_type=msg_type,
|
||||
source=source,
|
||||
message_id=raw.get("message_id"),
|
||||
reply_to_message_id=raw.get("reply_to_message_id"),
|
||||
media_urls=raw.get("media_urls") or [],
|
||||
)
|
||||
|
||||
|
||||
class InboundDeliveryReceiver:
|
||||
"""Verifies + dispatches signed connector→gateway inbound deliveries.
|
||||
|
||||
Transport-agnostic core: ``handle_raw`` takes the raw body bytes + headers +
|
||||
which route was hit and returns ``(status, body)``. The aiohttp wiring
|
||||
(``build_app`` / ``serve``) is a thin shell so the verify+dispatch logic is
|
||||
unit-testable without a live socket.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
delivery_key_verify_list: Callable[[], Sequence[str]],
|
||||
on_message: InboundMessageHandler,
|
||||
on_interrupt: Optional[InboundInterruptHandler] = None,
|
||||
max_skew_seconds: int = 300,
|
||||
) -> None:
|
||||
# A callable (not a static list) so a rotated delivery key is picked up
|
||||
# without rebuilding the receiver — mirrors the connector's verify list.
|
||||
self._verify_list = delivery_key_verify_list
|
||||
self._on_message = on_message
|
||||
self._on_interrupt = on_interrupt
|
||||
self._max_skew_seconds = max_skew_seconds
|
||||
|
||||
async def handle_raw(
|
||||
self, *, raw_body: bytes, timestamp: Optional[str], signature: Optional[str], is_interrupt: bool
|
||||
) -> tuple[int, dict]:
|
||||
"""Verify the signature over ``raw_body`` and dispatch. Returns (status, json).
|
||||
|
||||
401 on a missing/invalid/expired signature (never dispatches unverified).
|
||||
400 on malformed JSON. 200 on a verified, dispatched delivery.
|
||||
"""
|
||||
verify_keys = list(self._verify_list() or [])
|
||||
if not verify_keys:
|
||||
# No delivery key provisioned -> we cannot verify -> reject. A gateway
|
||||
# that hasn't enrolled must not accept inbound (fail closed).
|
||||
logger.warning("relay inbound: no delivery key configured; rejecting")
|
||||
return 401, {"error": "no delivery key configured"}
|
||||
|
||||
# Verify over the EXACT raw bytes the connector signed. Decode to text
|
||||
# with the same UTF-8 the connector's JSON.stringify produced; a single
|
||||
# differing byte breaks the HMAC (raw-body-preservation discipline).
|
||||
body_text = raw_body.decode("utf-8", errors="strict")
|
||||
if not verify_delivery_signature(
|
||||
body_text, timestamp, signature, verify_keys, self._max_skew_seconds
|
||||
):
|
||||
return 401, {"error": "invalid delivery signature"}
|
||||
|
||||
try:
|
||||
payload = json.loads(body_text)
|
||||
except json.JSONDecodeError:
|
||||
return 400, {"error": "invalid JSON body"}
|
||||
|
||||
if is_interrupt or payload.get("type") == "interrupt":
|
||||
session_key = str(payload.get("session_key", ""))
|
||||
chat_id = str(payload.get("chat_id", "") or payload.get("reason", "") or "")
|
||||
if self._on_interrupt is not None and session_key:
|
||||
await self._on_interrupt(session_key, chat_id)
|
||||
return 200, {"ok": True}
|
||||
|
||||
# Default: a normalized inbound message event.
|
||||
event_raw = payload.get("event")
|
||||
if not isinstance(event_raw, dict):
|
||||
return 400, {"error": "missing event"}
|
||||
event = _event_from_wire(event_raw)
|
||||
await self._on_message(event)
|
||||
return 200, {"ok": True}
|
||||
|
||||
# ── aiohttp wiring (thin shell over handle_raw) ──────────────────────
|
||||
def build_app(self) -> Any:
|
||||
"""Build an aiohttp Application exposing the delivery + interrupt routes."""
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"InboundDeliveryReceiver requires the 'aiohttp' package "
|
||||
"(install the messaging extra)."
|
||||
)
|
||||
|
||||
async def _deliver(request: Any) -> Any:
|
||||
return await self._respond(request, is_interrupt=False)
|
||||
|
||||
async def _interrupt(request: Any) -> Any:
|
||||
return await self._respond(request, is_interrupt=True)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/healthz", lambda _: web.Response(text="ok"))
|
||||
app.router.add_post("/", _deliver)
|
||||
app.router.add_post("/interrupt", _interrupt)
|
||||
return app
|
||||
|
||||
async def _respond(self, request: Any, *, is_interrupt: bool) -> Any:
|
||||
# Read the RAW bytes — do NOT use request.json() (it reparses and we'd
|
||||
# verify over a re-serialized form, breaking the HMAC).
|
||||
raw_body = await request.read()
|
||||
status, body = await self.handle_raw(
|
||||
raw_body=raw_body,
|
||||
timestamp=request.headers.get(DELIVERY_TS_HEADER),
|
||||
signature=request.headers.get(DELIVERY_SIG_HEADER),
|
||||
is_interrupt=is_interrupt,
|
||||
)
|
||||
return web.json_response(body, status=status)
|
||||
Loading…
Add table
Add a link
Reference in a new issue