Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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
Reference in New Issue
Block a user