"""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.`` 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 (``**:** ``). ``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