Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+967
View File
@@ -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
+168
View File
@@ -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)
+150
View File
@@ -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")],
},
]
+193
View File
@@ -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),
)
+214
View File
@@ -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"]
+143
View File
@@ -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