hermes-agent/tools/managed_tool_gateway.py
rob-maron 07447bd5db
Some checks are pending
CI / Detect affected areas (push) Waiting to run
CI / Python tests (push) Blocked by required conditions
CI / Python lints (push) Blocked by required conditions
CI / JS & TS checks (push) Blocked by required conditions
CI / Desktop E2E (push) Blocked by required conditions
CI / Docs Site (push) Blocked by required conditions
CI / Deny unrelated histories (push) Blocked by required conditions
CI / Check contributors (push) Blocked by required conditions
CI / Check uv.lock (push) Blocked by required conditions
CI / Check no committed infographics (push) Blocked by required conditions
CI / package-lock.json diff (push) Blocked by required conditions
CI / Lint Docker scripts (push) Blocked by required conditions
CI / Build&Test Docker image (push) Blocked by required conditions
CI / Supply-chain scan (push) Blocked by required conditions
CI / Review label gate (push) Blocked by required conditions
CI / OSV scan (push) Waiting to run
CI / CI review comment (live) (push) Blocked by required conditions
CI / All required checks pass (push) Blocked by required conditions
CI / CI timing report (push) Blocked by required conditions
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Waiting to run
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Blocked by required conditions
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Docker Build, Test, and Publish / merge (push) Blocked by required conditions
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Waiting to run
auto-fix lint issues & formatting / Apply patch (push) Blocked by required conditions
nous portal video gen (#74963)
2026-07-30 14:52:15 -04:00

422 lines
16 KiB
Python

"""Generic managed-tool gateway helpers for Nous-hosted vendor passthroughs."""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Callable, Optional
from urllib.parse import urlsplit
logger = logging.getLogger(__name__)
from hermes_constants import get_hermes_home
from tools.tool_backend_helpers import managed_nous_tools_enabled
_DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com"
_DEFAULT_TOOL_GATEWAY_SCHEME = "https"
_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
@dataclass(frozen=True)
class ManagedToolGatewayConfig:
vendor: str
gateway_origin: str
nous_user_token: str
managed_mode: bool
def auth_json_path():
"""Return the Hermes auth store path, respecting HERMES_HOME overrides."""
return get_hermes_home() / "auth.json"
def _read_nous_provider_state() -> Optional[dict]:
try:
path = auth_json_path()
if not path.is_file():
return None
data = json.loads(path.read_text(encoding="utf-8"))
providers = data.get("providers", {})
if not isinstance(providers, dict):
return None
nous_provider = providers.get("nous", {})
if isinstance(nous_provider, dict):
return nous_provider
except Exception:
pass
return None
def _parse_timestamp(value: object) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _access_token_is_expiring(expires_at: object, skew_seconds: int) -> bool:
expires = _parse_timestamp(expires_at)
if expires is None:
return True
remaining = (expires - datetime.now(timezone.utc)).total_seconds()
return remaining <= max(0, int(skew_seconds))
def peek_nous_access_token() -> Optional[str]:
"""Cheap probe for a Nous gateway token without triggering refresh.
Availability scans (`hermes tools`, banner/status paint, provider
`is_available()` checks) must stay off the synchronous OAuth refresh path.
This helper therefore only inspects the explicit env override and the
cached auth-store token, without checking expiry and without making any
network calls. Truthful refresh handling stays in request/session paths
that call :func:`read_nous_access_token`.
"""
explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN")
if isinstance(explicit, str) and explicit.strip():
return explicit.strip()
nous_provider = _read_nous_provider_state() or {}
access_token = nous_provider.get("access_token")
if isinstance(access_token, str) and access_token.strip():
return access_token.strip()
return None
def read_nous_access_token() -> Optional[str]:
"""Read a Nous Subscriber OAuth access token from auth store or env override."""
explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN")
if isinstance(explicit, str) and explicit.strip():
return explicit.strip()
nous_provider = _read_nous_provider_state() or {}
cached_token = peek_nous_access_token()
if cached_token and not _access_token_is_expiring(
nous_provider.get("expires_at"),
_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
):
return cached_token
try:
from hermes_cli.auth import resolve_nous_access_token
refreshed_token = resolve_nous_access_token(
refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
)
if isinstance(refreshed_token, str) and refreshed_token.strip():
return refreshed_token.strip()
except Exception as exc:
logger.debug("Nous access token refresh failed: %s", exc)
return cached_token
def get_tool_gateway_scheme() -> str:
"""Return configured shared gateway URL scheme."""
scheme = os.getenv("TOOL_GATEWAY_SCHEME", "").strip().lower()
if not scheme:
return _DEFAULT_TOOL_GATEWAY_SCHEME
if scheme in {"http", "https"}:
return scheme
raise ValueError("TOOL_GATEWAY_SCHEME must be 'http' or 'https'")
def build_vendor_gateway_url(vendor: str) -> str:
"""Return the gateway origin for a specific vendor."""
vendor_key = f"{vendor.upper().replace('-', '_')}_GATEWAY_URL"
explicit_vendor_url = os.getenv(vendor_key, "").strip().rstrip("/")
if explicit_vendor_url:
return explicit_vendor_url
shared_scheme = get_tool_gateway_scheme()
shared_domain = os.getenv("TOOL_GATEWAY_DOMAIN", "").strip().strip("/")
if shared_domain:
return f"{shared_scheme}://{vendor}-gateway.{shared_domain}"
return f"{shared_scheme}://{vendor}-gateway.{_DEFAULT_TOOL_GATEWAY_DOMAIN}"
def resolve_managed_tool_gateway(
vendor: str,
gateway_builder: Optional[Callable[[str], str]] = None,
token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> Optional[ManagedToolGatewayConfig]:
"""Resolve shared managed-tool gateway config for a vendor."""
if not managed_nous_tools_enabled():
return None
resolved_gateway_builder = gateway_builder or build_vendor_gateway_url
resolved_token_reader = token_reader or read_nous_access_token
gateway_origin = resolved_gateway_builder(vendor)
nous_user_token = resolved_token_reader()
if not gateway_origin or not nous_user_token:
return None
return ManagedToolGatewayConfig(
vendor=vendor,
gateway_origin=gateway_origin,
nous_user_token=nous_user_token,
managed_mode=True,
)
def is_managed_tool_gateway_ready(
vendor: str,
gateway_builder: Optional[Callable[[str], str]] = None,
token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> bool:
"""Return True when gateway URL and a likely-usable Nous token are present.
Defaults to :func:`peek_nous_access_token` so read-only availability scans
avoid synchronous OAuth refresh. Callers that are about to make a real
gateway request should use :func:`resolve_managed_tool_gateway` (which
still defaults to the refresh-aware :func:`read_nous_access_token`).
"""
return resolve_managed_tool_gateway(
vendor,
gateway_builder=gateway_builder,
token_reader=token_reader or peek_nous_access_token,
) is not None
# ---------------------------------------------------------------------------
# Managed vendor endpoints
# ---------------------------------------------------------------------------
#
# Vendors the gateway serves on its own origin (rather than on a
# `{vendor}-gateway` host) are pinned HERE, in code, the same way every other
# managed vendor's gateway URL is pinned: adding one is a Hermes release, and
# the exact URL a user's agent may connect to is reviewable in this file. A
# runtime discovery catalog was tried and deliberately removed — a remote
# endpoint that can add tools to every entitled install is a bigger trust
# surface than a code diff.
#
# The gateway exposes a Nous-owned REST contract per vendor; it names the
# vendor but not the vendor's own API, so nothing here needs to know the
# upstream's endpoint or field names.
# Pseudo-vendor used only to resolve the shared tool-gateway origin via
# build_vendor_gateway_url (honors TOOL_GATEWAY_URL / TOOL_GATEWAY_DOMAIN).
_MANAGED_GATEWAY_VENDOR = "tool"
def managed_vendor_base_path(vendor: str) -> str:
"""Base path for a managed vendor's REST routes on the gateway host."""
return f"/api/{vendor}"
def managed_vendor_upload_path(vendor: str) -> str:
"""Media upload endpoint for a managed vendor, on the same host."""
return f"/api/uploads/{vendor}"
def managed_vendor_endpoints(
vendor: str,
gateway_builder: Optional[Callable[[str], str]] = None,
) -> Optional[dict]:
"""Absolute URLs for a managed vendor, or ``None`` when unreachable.
``None`` means managed Nous tools are disabled for this build, which is
what keeps a user who could never use the vendor from being shown its
tools.
"""
if not managed_nous_tools_enabled():
return None
builder = gateway_builder or build_vendor_gateway_url
origin = builder(_MANAGED_GATEWAY_VENDOR).rstrip("/")
return {
"origin": origin,
"base_url": f"{origin}{managed_vendor_base_path(vendor)}",
"upload_path": managed_vendor_upload_path(vendor),
}
def is_managed_nous_gateway_url(
url: object,
gateway_builder: Optional[Callable[[str], str]] = None,
) -> bool:
"""True when ``url`` is on the Nous tool-gateway origin this client builds.
Anything granting a URL extra trust — our bearer, reading files off disk to
upload — must gate on this rather than on a name, so an arbitrary URL can
never inherit that trust.
"""
if not isinstance(url, str) or not url.strip():
return False
builder = gateway_builder or build_vendor_gateway_url
try:
expected = urlsplit(builder(_MANAGED_GATEWAY_VENDOR))
actual = urlsplit(url.strip())
except ValueError:
return False
return bool(actual.scheme) and (actual.scheme, actual.netloc) == (expected.scheme, expected.netloc)
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 {}
return {"Authorization": f"Bearer {token.strip()}"}
# ---------------------------------------------------------------------------
# Managed media uploads
# ---------------------------------------------------------------------------
#
# Media arguments used to be inlined as base64, which capped a whole tool call
# at ~2MB of real bytes under the gateway's request ceiling and ruled out video
# entirely. Each pinned managed server carries an upload endpoint
# (`upload_path`); the bytes go straight to storage via a presigned URL, and
# the tool argument carries an opaque `nous-upload:<token>` reference instead.
#
# The protocol lives HERE rather than in a vendor tool module: the presign
# request shape, the response contract, and the `nous-upload:` scheme are Nous
# gateway specifics shared by every managed vendor that takes media.
_MEDIA_UPLOAD_PRESIGN_TIMEOUT_SECONDS = 15.0
# The PUT carries up to 50MB of video; a flat 60s would fail a legitimate
# clip on an ordinary residential uplink, so only the write phase is long.
_MEDIA_UPLOAD_PUT_READ_TIMEOUT_SECONDS = 60.0
_MEDIA_UPLOAD_PUT_WRITE_TIMEOUT_SECONDS = 300.0
def _describe_media_upload_refusal(response) -> str:
"""A model-actionable reason from a gateway refusal, or a generic one.
The gateway's 4xx bodies carry deliberate guidance (rate-limit waits, size
caps, "you could not submit anyway"), so surface `error.message` verbatim
rather than a bare status code.
"""
try:
payload = response.json()
message = payload.get("error", {}).get("message")
if isinstance(message, str) and message.strip():
return message.strip()
except Exception:
pass
return f"the gateway refused the upload (HTTP {response.status_code})"
def build_managed_media_uploader(
server_url: object,
upload_path: object,
gateway_builder: Optional[Callable[[str], str]] = None,
token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> Optional[Callable]:
"""Async ``(data, mime) -> argument value`` uploader for one managed vendor.
Returns ``None`` when there is no usable upload endpoint (not a managed
Nous URL, or no ``upload_path``); callers then refuse local paths with a
clear message instead of silently forwarding them.
The three steps of the protocol:
1. POST ``origin + upload_path`` with the declared content type and exact
byte length, using the same live auth headers as the vendor calls.
The gateway answers with a presigned single-object PUT URL (short
expiry; type and length are signed into it) and an upload token.
2. PUT the bytes to that URL. This goes directly to storage — never
through the gateway — which is what removes the request-size ceiling.
3. Return ``nous-upload:<token>`` for the tool argument. The token is
bound to this Nous principal and is redeemable only through the
gateway, so it is inert anywhere else it might end up.
"""
if not is_managed_nous_gateway_url(server_url, gateway_builder):
return None
if not isinstance(upload_path, str) or not upload_path.startswith("/"):
return None
parts = urlsplit(str(server_url).strip())
origin = f"{parts.scheme}://{parts.netloc}"
presign_url = f"{origin}{upload_path}"
async def upload(data: bytes, mime: str) -> str:
import httpx
from tools.url_safety import create_ssrf_safe_async_client
headers = managed_gateway_auth_headers(server_url, gateway_builder, token_reader)
if not headers:
raise RuntimeError("no Nous credential is available for the upload")
# Two clients on purpose, split by whose address we are trusting.
#
# The presign POST goes to `presign_url`, which is entirely determined
# by the managed gateway origin (already validated by
# is_managed_nous_gateway_url) plus the pinned upload_path — the same
# first-party host the vendor calls go to freely. SSRF-guarding it
# protects against nothing and would reject a local gateway on
# 127.0.0.1, so it uses a plain client. The PUT target, by contrast, is
# a URL the gateway *returned*, so it keeps the SSRF-safe client as
# defense in depth (real presigned URLs are public R2, which it allows).
presign_timeout = httpx.Timeout(_MEDIA_UPLOAD_PRESIGN_TIMEOUT_SECONDS)
async with httpx.AsyncClient(timeout=presign_timeout) as client:
presign = await client.post(
presign_url,
headers=headers,
json={"contentType": mime, "contentLength": len(data)},
)
if presign.status_code != 200:
raise RuntimeError(_describe_media_upload_refusal(presign))
try:
payload = presign.json()
except Exception:
payload = None
upload_url = payload.get("uploadUrl") if isinstance(payload, dict) else None
token = payload.get("token") if isinstance(payload, dict) else None
if not (isinstance(upload_url, str) and upload_url and isinstance(token, str) and token):
raise RuntimeError("the gateway's upload response was malformed")
put_timeout = httpx.Timeout(
_MEDIA_UPLOAD_PRESIGN_TIMEOUT_SECONDS,
read=_MEDIA_UPLOAD_PUT_READ_TIMEOUT_SECONDS,
write=_MEDIA_UPLOAD_PUT_WRITE_TIMEOUT_SECONDS,
)
async with create_ssrf_safe_async_client(timeout=put_timeout) as client:
# The presigned URL signs the exact Content-Type and Content-Length,
# so this PUT must send precisely what was declared above.
put = await client.put(upload_url, content=data, headers={"Content-Type": mime})
if put.status_code != 200:
raise RuntimeError(f"storage refused the upload (HTTP {put.status_code})")
return f"nous-upload:{token}"
return upload