Files
hermes-agent/gateway/relay/__init__.py
T
Teknium 3c7069bdcb refactor(gateway): dead-code removal, helper unification, defensive-layer collapse and rationale-preserving comment compaction across 40 modules
authz_mixin, browser_control_broker, delivery, delivery_ledger, display_config, drain_control,
hosted_room_links/peer/policy_checkpoint, hosted_rooms, platform_registry, relay/__init__,
relay/ws_transport, run.py and slash_commands.py (comments), session_context, session_state,
streaming_tts_consumer, turn_lease and small modules.

- HostedRoomPolicyCheckpoint._apply_event -> per-kind handler table
- WebSocketRelayTransport._handle_frame -> frame-handler table
- GatewayAuthorizationMixin: unified adapter setting/flag/extra readers
- dead symbols removed (verified zero references): RoomLinkProbe/select_room_link,
  relay_bot_username, is_restart_loop_tripped, debug_rows, DeadTargetRegistry.all_dead,
  BrowserControlBroker.detach_owner/_prune_tickets, StreamingTTSConsumer.started/_enqueue_done/
  _iter_stream_chunks/_next_stream_chunk, RecoverableHandleCache.status_for, _auth_env,
  _copy_default_catalog, _parse_timestamp_prefix, _present_* helpers, _send_result_error_kind,
  _truthy_env, SessionFieldView/TurnLeaseTokenView dunder shims, and their orphaned tests.
- lost WHY/invariant text from the earlier compaction restored compactly (541 hunks audited)
2026-09-02 13:30:50 -07:00

717 lines
31 KiB
Python

"""Relay/connector support package for the Hermes gateway.
EXPERIMENTAL. 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 have shaken out the schema. See ``docs/relay-connector-contract.md``.
Activation is config-driven, not a 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) — the same shape as ``gateway.proxy_url``.
"""
from __future__ import annotations
import json
import logging
import os
import re
import socket
import urllib.error
import urllib.parse
import urllib.request
from typing import Optional
logger = logging.getLogger("gateway.relay")
# Shape gate for ambient-endpoint token bodies (mode 1b in
# _resolve_relay_identity_token): a JWT (3+ dotted segments) or a long opaque
# base64url token (>= 32 chars). Short bare words — 'unauthorized', 'error',
# 'null' — match the alphabet but are plain-text error bodies 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
)
_HTTP_TIMEOUT_S = 15.0
# ─────────────────────────── config access ───────────────────────────
def _load_cfg() -> dict:
"""The full gateway config, or ``{}`` — config absence/parse must never crash boot."""
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = _load_gateway_config()
except Exception: # noqa: BLE001
return {}
return cfg if isinstance(cfg, dict) else {}
def _gateway_cfg() -> dict:
"""The ``gateway:`` block of config.yaml, or ``{}``."""
block = _load_cfg().get("gateway")
return block if isinstance(block, dict) else {}
def _env_or_cfg(env_var: str, cfg_key: str) -> str:
"""Env var first (Docker/NAS stamp), then ``gateway.<cfg_key>`` in config.yaml.
Returns the stripped value, ``""`` when neither is set.
"""
value = os.environ.get(env_var, "").strip()
return value or str(_gateway_cfg().get(cfg_key, "") or "").strip()
def relay_url() -> Optional[str]:
"""The connector relay endpoint URL, or None when relay is not configured.
``GATEWAY_RELAY_URL`` env, then ``gateway.relay_url``. A non-empty value
activates the relay platform; absence means a normal direct gateway.
"""
return _env_or_cfg("GATEWAY_RELAY_URL", "relay_url").rstrip("/") or None
def relay_platform_identities() -> list[tuple[str, str]]:
"""The ordered (platform, bot_id) pairs this gateway fronts over the relay.
One gateway fronts a SET of platforms on one WS connection, from the env-stamped
deploy config: ``GATEWAY_RELAY_PLATFORMS`` (comma-sep, e.g. ``discord,telegram``)
and ``GATEWAY_RELAY_BOT_IDS`` (JSON map ``{"discord": {"botId": "..."}, ...}``).
The FIRST pair is the default the handshake/descriptor falls back to. A platform
listed 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", "")]`` — the generic single-plane fallback — when nothing is 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]:
"""Logical platform names the relay fronts, minus the generic ``relay`` fallback.
Same env source ``ws_transport`` seeds the live adapter's identity set from, so
config-time validation (cron delivery preflight) and fire-time routing can never
disagree — and it needs no live adapter, so a standalone scheduler can use it.
"""
return {p for p, _ in relay_platform_identities() if p != "relay"}
def _relay_bot_ids_map() -> dict:
"""Parse ``GATEWAY_RELAY_BOT_IDS``; a malformed map yields ``{}`` rather than crashing boot."""
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
logger.warning("GATEWAY_RELAY_BOT_IDS is not valid JSON; treating as empty")
return {}
def relay_platform_identity() -> tuple[str, str]:
"""The PRIMARY (platform, bot_id) — first of ``relay_platform_identities()``."""
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 (``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` env,
then ``gateway.relay_id`` / ``gateway.relay_secret``). Either absent ->
``(None, None)`` and the transport dials unauthenticated.
"""
gateway_id = _env_or_cfg("GATEWAY_RELAY_ID", "relay_id")
secret = _env_or_cfg("GATEWAY_RELAY_SECRET", "relay_secret")
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 here and stores it on the tenant's
route rows; gateway-asserted but scoped to the verified tenant, so a dishonest
gateway can only misdirect its OWN inbound. ``GATEWAY_RELAY_ENDPOINT`` env
(self-hosted or NAS-stamped), then ``gateway.relay_endpoint``. Absent -> the
gateway provisions outbound-only (no inbound routes written).
"""
return _env_or_cfg("GATEWAY_RELAY_ENDPOINT", "relay_endpoint").rstrip("/") or None
def relay_route_keys() -> list[str]:
"""Discriminators (scope_ids / chat_ids / paths) this gateway's tenant owns.
Paired with ``relay_endpoint()``: the connector writes one route row per
(routeKey -> tenant, endpoint), so keys only take effect alongside an endpoint.
``GATEWAY_RELAY_ROUTE_KEYS`` is comma-separated; ``gateway.relay_route_keys``
may be a list or a comma string. Empty -> outbound-only provisioning.
"""
raw = os.environ.get("GATEWAY_RELAY_ROUTE_KEYS", "").strip()
if not raw:
val = _gateway_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()
return [k.strip() for k in raw.split(",") if k.strip()]
def relay_instance_id() -> Optional[str]:
"""Stable per-instance id forwarded at provision (``GATEWAY_RELAY_INSTANCE_ID`` / ``gateway.relay_instance_id``).
Binds the connector's ``gatewayId -> instanceId`` so inbound can route
per-instance rather than tenant-broadcast. NAS stamps its ``AgentInstance.id``
for a managed agent; a self-hosted operator may set it. Gateway-asserted but
tenant-scoped like ``relay_endpoint()``. Absent -> the connector stores null
(back-compat: no per-instance binding yet).
"""
return _env_or_cfg("GATEWAY_RELAY_INSTANCE_ID", "relay_instance_id") or None
def relay_wake_url() -> Optional[str]:
"""The gateway's WAKE URL forwarded at provision (``GATEWAY_RELAY_WAKE_URL`` / ``gateway.relay_wake_url``).
A poke target the connector GETs (payload-free) when a going-idle destination for
this instance receives its first buffered event, so a suspended gateway wakes,
reconnects its relay WS and drains its backlog. NAS stamps it for a managed
container; a self-hosted operator sets it (or ``hermes gateway enroll --wake-url``).
Tenant-scoped like ``relay_instance_id()``. Absent -> the connector can't wake
this instance (buffering still works; the gateway drains on next reconnect).
"""
return _env_or_cfg("GATEWAY_RELAY_WAKE_URL", "relay_wake_url").rstrip("/") or None
def relay_display_name() -> Optional[str]:
"""The human-facing agent display name forwarded at provision.
Primary source of the connector's multi-agent reply-attribution prefix
(``**<displayName>:** ``). ``GATEWAY_RELAY_DISPLAY_NAME`` env, then the skin's
branded agent name (the CLI banner value), so a skin rename propagates on the
next boot's re-provision. Absent -> the connector falls back to the instance's
linked-owner identity, else skips the prefix.
"""
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 is identical on every default install: forwarding it would
# prefix every reply "**Hermes Agent:**" and shadow the connector's
# linked-owner fallback, which actually disambiguates. Only a customized
# name is forwarded.
if value == "Hermes Agent":
value = ""
# Mirror the connector's ingest sanitization (trim + 64-char cap).
return value[:64] or None
# ─────────────────────────── connector HTTP ───────────────────────────
def _http_base(relay_dial_url: str) -> str:
"""``ws(s)://…/relay`` dial URL -> ``http(s)://…`` connector base (shared with media)."""
from gateway.relay.media import media_base_url
return media_base_url(relay_dial_url)
def _provision_url(relay_dial_url: str) -> str:
"""Map the dial URL to the ``/relay/provision`` POST URL."""
return f"{_http_base(relay_dial_url)}/relay/provision"
def _policy_url(relay_dial_url: str) -> str:
"""Map the dial URL to the ``/relay/policy`` POST URL (relevance-policy channel)."""
return f"{_http_base(relay_dial_url)}/relay/policy"
def _json_post_request(url: str, token: str, body: dict) -> urllib.request.Request:
"""A bearer-authenticated JSON POST request to the connector."""
return urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
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 reasons over a platform-agnostic policy, keyed by
``(tenant, platform, instanceId)``. Mapping from the agent's existing knobs:
- ``requireAddress`` <- the platform's ``require_mention``
- ``freeResponseScopes`` <- the platform's ``free_response_channels``
- ``allowOtherBots`` <- ``{PLATFORM}_ALLOW_BOTS`` in {"mentions","all"}
Read from the platform's config block (``discord:``), falling back to the bridged
top-level keys, then env. ``platform`` defaults to the PRIMARY fronted platform.
Returns None when relay isn't configured or nothing is CONFIGURED to declare, so
the connector's default (mention-gated) applies. The condition is "require_mention
is unset", NOT "falsy": 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 platform is None:
platform, _bot_id = relay_platform_identity()
if not platform or platform == "relay":
return None
require_mention = None
free_response: list[str] = []
try:
cfg = _load_cfg()
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
# 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 => keep the connector's default. The test is
# "require_mention is unset", NOT "falsy": an EXPLICIT `require_mention: false`
# is a non-default choice that MUST be declared (see docstring).
if require_mention is None and not free_response and not allow_other_bots:
return None
return {
"platform": platform,
"requireAddress": bool(require_mention),
"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 = _HTTP_TIMEOUT_S,
) -> 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 route rows
and returns ``{secret, deliveryKey, tenant, gatewayId, routeKeys}``. Raises
RuntimeError with a user-facing message on any non-2xx / transport failure.
"""
body: dict = {
"gatewayId": gateway_id,
"platform": platform,
"botId": bot_id,
"gatewayEndpoint": gateway_endpoint or "",
"routeKeys": route_keys,
}
# Optional fields are OMITTED when absent so the connector stores null
# (back-compat) rather than binding an empty string.
for key, value in (("instanceId", instance_id), ("wakeUrl", wake_url), ("displayName", display_name)):
if value:
body[key] = value
req = _json_post_request(provision_url, access_token, body)
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: # noqa: BLE001
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 runtime self-provision and ``hermes gateway enroll``.
Modes, in precedence order:
1. Generic OIDC client-credentials (self-hosted IdP, no Nous Portal): when
``gateway.idp.token_url`` (``GATEWAY_RELAY_IDP_TOKEN_URL``) is set together
with client id + secret, POST the OAuth2 ``client_credentials`` grant.
1b. Ambient token endpoint: ``token_url`` with NEITHER client_id nor
client_secret is a metadata-server-style endpoint (e.g. Domino's
``$DOMINO_API_PROXY/access-token``): a plain GET whose body IS the token,
raw JWT or a JSON envelope with ``access_token``. Possession of the
(typically loopback) endpoint is the credential.
2. Nous Portal (default): ``resolve_nous_access_token()``.
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:
idp = _gateway_cfg().get("idp")
idp = idp if isinstance(idp, dict) else {} # malformed gateway.idp degrades to "no token_url"
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()
if not token_url:
from hermes_cli.auth import resolve_nous_access_token
return resolve_nous_access_token()
if not client_id and not client_secret:
# Mode 1b — 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=_HTTP_TIMEOUT_S) 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
# No shape gate on an envelope: it is a deliberate token response, and
# opaque tokens may use the standard-base64 alphabet the raw gate rejects.
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: a mistyped client_credentials setup, not an
# ambient endpoint. Fail loud; 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 — 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=_HTTP_TIMEOUT_S) 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_url()`` is set and NO per-gateway secret is pinned: resolves
the agent's identity token, POSTs ``/relay/provision`` for EACH fronted platform
and sets ``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` /
``GATEWAY_RELAY_DELIVERY_KEY`` in ``os.environ`` so ``register_relay_adapter()``
picks them up. Creds live ONLY in process memory (never ``~/.hermes/.env``), so a
hosted container re-provisions every boot; the connector's rotation window covers
a still-connected prior instance.
The trigger is deliberately NOT ``is_managed()`` (False on a NAS-hosted Fly
agent); "pointed at a connector without a pinned secret" is the real signal and
self-guards: an enrolled self-hosted gateway has a PINNED secret -> skipped; a
box with no resolvable identity -> graceful no-op.
Returns True iff it provisioned. NEVER raises: a failure logs and returns False so
the gateway still boots (the adapter then dials unauthenticated / is rejected).
"""
dial_url = relay_url()
if not dial_url:
return False
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
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.
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()
# Provision EACH fronted platform under the SAME gatewayId + the SAME per-gateway
# secret: the connector's secret record is (gatewayId -> tenant) only, platform/
# botId live on per-platform route rows, so N POSTs with one gatewayId are
# idempotent on the secret and additive on routes. PARTIAL-FAILURE-TOLERANT: a
# platform that fails is logged and skipped (just not fronted); the others come up.
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 (the per-gateway secret
# authenticates the outbound WS upgrade). Never logged.
if 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
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),
str(result.get("tenant") or "") 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 = _HTTP_TIMEOUT_S) -> int:
"""POST the relevance policy to ``/relay/policy``; return the HTTP status.
Authenticated with the gateway's own upgrade token (``make_upgrade_token``), so
the connector resolves ``{tenant, instanceId}`` from its stored secret record,
never the body. Raises RuntimeError on transport failure.
"""
req = _json_post_request(policy_url, token, policy)
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, per fronted platform.
Runs at boot AFTER the per-gateway secret is resolved. The connector stores the
policy per-instance and 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. Re-declared
every boot (idempotent full replace). A platform with nothing non-default to
declare is skipped; a failed POST for one platform doesn't block the others.
NEVER raises and NEVER blocks boot: relevance is an optimization layered on the
authorization gate, so a failed declaration just leaves the connector's prior
policy. Returns True iff the connector accepted at least one policy (HTTP 200).
"""
dial_url = relay_url()
if not dial_url:
return False
gateway_id, secret = relay_connection_auth()
if not gateway_id or not secret:
# Can't authenticate the POST (and there's no instance to attach a policy to).
return False
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:
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). Returns True if registration happened. With a
URL the factory builds a live ``WebSocketRelayTransport``; the adapter negotiates
the real ``CapabilityDescriptor`` at ``connect()`` 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.
# With no URL (force/test) the adapter is transport-less and keeps it.
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,
# The full SET of identities: one hello per identity (the connector
# accumulates them) and the per-frame egress botId resolves from it.
identities=relay_platform_identities(),
gateway_id=gateway_id,
upgrade_secret=upgrade_secret,
# Re-dial + re-handshake after an unexpected close so a gateway that
# went idle re-establishes its socket, which triggers the connector's
# buffered-flip drain 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