Files
hermes-agent/tools/managed_gateway_auth.py
Siddharth Balyan e375e86a6c feat(connectors): name the caller on every gateway request
One module, tools/managed_gateway_identity.py, builds the caller
identity:

- user_agent(): hermes-agent/<base_version> (<surface>; <os_family>),
  surface from the session platform through the shared-metrics surface
  map. Every managed-gateway client gets it through
  managed_gateway_auth_headers; the connector clients add it per attempt.
- correlation_headers(call): x-request-id per HTTP attempt plus the
  conversation, turn and tool call ids, only when the profile has opted
  in to sending telemetry. The x-nous- prefix is what the gateway strips
  before a vendor call.
- log_connector_outcome(): one line per connector HTTP outcome with the
  gateway's x-request-id, WARNING on failure, DEBUG on success and on
  the account route's not-created-yet 404.

ConnectorClient assembles an attempt's headers in one method,
_attempt_headers, and records the response request id on the raised
error. The connect request gains an optional initiator field; a gateway
that predates it ignores unknown fields.
2026-10-13 01:35:42 +05:30

81 lines
2.9 KiB
Python

"""First-party gateway bearer trust, ported from connectors-only 43608084f4."""
import logging
from typing import Callable, Optional
from urllib.parse import urlsplit
from tools.managed_tool_gateway import build_vendor_gateway_url, read_nous_access_token
logger = logging.getLogger(__name__)
def managed_gateway_origin() -> str:
"""Origin for on-origin managed vendors and media uploads."""
return build_vendor_gateway_url("tool")
def connector_gateway_origin() -> str:
"""Separate connector deployment; honors CONNECTOR_GATEWAY_URL."""
return build_vendor_gateway_url("connector")
def is_managed_nous_gateway_url(
url: object,
gateway_builder: Optional[Callable[[str], str]] = None,
) -> bool:
"""True when ``url`` is on one of the first-party gateway origins we build.
Both first-party surfaces count: the media/on-origin-vendor host
(:func:`managed_gateway_origin`) and the connectors host
(:func:`connector_gateway_origin`). Each is compared as an exact
``(scheme, netloc)`` pair — never as a name or a domain suffix — so
``evil-connector-gateway.nousresearch.com.attacker.dev`` and an ``http``
downgrade of a real host both stay outside the set.
Anything granting a URL extra trust — our bearer, reading files off disk to
upload — must gate on this, so an arbitrary URL can never inherit it.
"""
if not isinstance(url, str) or not url.strip():
return False
build_origin = gateway_builder or build_vendor_gateway_url
try:
expected = {
urlsplit(build_origin(label))[:2]
for label in ("tool", "connector")
}
actual = urlsplit(url.strip())
except ValueError:
return False
return bool(actual.scheme) and (actual.scheme, actual.netloc) in expected
def managed_gateway_auth_headers(
url: object,
gateway_builder: Optional[Callable[[str], str]] = None,
token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> dict:
"""Live auth headers for a managed gateway URL, or ``{}`` when not managed.
Read fresh on every call rather than cached: a Nous access token expires
within the hour, and a long session would otherwise keep presenting a dead
bearer. Returns ``{}`` rather than raising when no token is available, so a
caller can report "sign in" instead of sending an unauthenticated request.
"""
if not is_managed_nous_gateway_url(url, gateway_builder):
return {}
resolved_token_reader = token_reader or read_nous_access_token
try:
token = resolved_token_reader()
except Exception as exc: # pragma: no cover — defensive
logger.debug("Managed gateway token read failed for %s: %s", url, exc)
return {}
if not isinstance(token, str) or not token.strip():
return {}
from tools.managed_gateway_identity import user_agent
return {"Authorization": f"Bearer {token.strip()}", "User-Agent": user_agent()}