* feat(desktop): give Button a loading prop that swaps label for spinner without layout shift The label stays in the box, invisible, and the spinner is absolutely centred over it, so a Connect or Approve button keeps its width while it works instead of collapsing to a spinner. The approval bar had the same thrash and moves onto it. * refactor(desktop): one consent card for connectors and MCP setup McpSetupTool rendered its own copy of the connector card's markup. It now renders ConnectorCard for the pending question and ConnectorSummary once settled, and the card gains what MCP needed: keyboard accelerators, a source line, a question heading. The card also gets an avatar variant (40px mark in the left gutter, text and buttons on one column) and a collapseWhenSettled switch so a connector can stay a full card with a green Connected pill in the action slot while MCP keeps its one-line summary. Brand marks for Gmail, Calendar, Drive, Discord, Telegram and Spotify; Slack via Tabler because simple-icons dropped the mark. * feat(desktop): connector card drives the agent through manage_connections wait The offer used to end in a Continue in chat button, and the agent, seeing an unconnected status, would improvise around the app. Now the card does what the TUI does. Clicking Connect opens the browser and sends one hidden line telling the agent to park in manage_connections action=wait for that slug and to never call connect again (a second link cancels the one being signed into). Not now sends its own line. A hidden request that lands while the turn is busy steers it, or queues if the turn just ended. Which call owns the live card changes too: consecutive calls naming the same apps are one exchange (connect, the wait, the status that follows), and the first of the last exchange is the card, so the agent's wait no longer demotes the card mid-authorization and mints a fresh one below it. A targeted ask renders one or two bare cards; only a real catalog gets the header, search and refresh. * feat(desktop): onboarding connects apps in chat and keeps tasks finishable without them The welcome chat knew connectors only as preferences to pick and wire up later, so asked to connect Gmail it invented a Settings page that does not exist. Both scripts now carry one rule set: status once, one batched connect for every app named, the card is the ask so write a line and end the turn, never route around a declined app with another client or credential. The build handoff checks real connection status instead of asserting none are connected, and the first task must be finishable, not free of, the apps they picked. The connectors card explains what connecting means and reports the count on its Continue button. * fix(tools): resolve the Nous identity for share_auth profiles in the connector gate A profile created with share_auth has no auth.json of its own and signs in through the root store. Every other credential reader falls back to the global root; the connector gate read HERMES_HOME/auth.json directly, saw nothing, and stripped manage_connections from the profile's tool list, so the welcome chat's agent truthfully reported the tool missing. The gate now goes through get_provider_auth_state. * fix(agent): name a provider retry backoff on the live status line The retry status is buffered and replays only when every retry fails, so during a 60s backoff after a 5xx the user saw a bare spinner. Right after a connector sign-in landed this read as the agent going silent. The backoff now also rewrites the live wait notice, which the desktop already renders in the thread status row; it is transient and clears on recovery. * test(desktop): connector rehearsal launcher and flagged connector spec connector-rehearsal.mjs starts the real desktop and backend under a fresh HERMES_HOME with no copied credentials, a fixed Vite port and CDP on 9344, so the onboarding connector flow can be driven end to end by hand or from outside. The Playwright spec covers the flagged connector step. * fix(desktop): send the agent back into wait when the user keeps waiting after a timeout The card's Keep waiting re-entered the poll but the agent's own wait had timed out too and nothing told it to go back in, so it would start talking mid-authorization. keepWaiting now fires onWaiting like connect does. Tests also pin that an expired or revoked grant asks the gateway for reconnect, not connect. * style(desktop): blank lines in connector-flow test per lint * feat(desktop): HERMES_SKIP_INTRO=1 / --skip-intro skips the first-run film The intro is a one-time reveal, so anyone rehearsing the guided chat behind it sits through it on every fresh HERMES_HOME. The flag rides the existing launch-flags path (main → preload → renderer) next to guestOnboarding and only gates isIntroRevealEnabled; the backend never sees it. The rehearsal launcher sets it. * fix(desktop): onboarding card Continue stays Done after the transcript rebuilds The card kept its Done flag in component state. The hidden submit and the turn-end hydrate both rebuild the message list, so the card remounted with the flag false and Continue came back live, letting a step be answered twice. The committed steps now live with the other onboarding answers, keyed by step, and the first-build chip pick rides the same store. remember_onboarding projects by key, so the new field never reaches USER.md. * fix(desktop): no provider picker or free-tier chip over the guided first launch Two sign-in surfaces leaked into the guide. A credential probe on the setup profile (a free-tier token mid refresh, a session before its runtime settled) hit requestDesktopOnboarding and dropped the provider picker over the chat the user was in; and the statusbar free-tier chip sat there offering a second sign-in the whole time. Both now yield while the gate phase is cinematic, guided or handoff. The free tier is the provider for those phases, and the guide offers sign-in on its own ready screen. * fix(desktop): onboarding connector picks are real catalog slugs The picker offered Spotify, GitHub and Stripe, none of which the deployed connector catalog carries, and spelled Calendar and Drive with hyphens the gateway does not use. A pick the build chat could not honour ended as "Spotify isn't in the connector list" after the user had been told to expect it. The list is now twelve slugs from the live status catalog, spelled as the gateway spells them; GitHub is out (the terminal has git and gh), chat channels stay on Messaging. Marks for the new entries; the Google marks answer both spellings. The build runbook offers the picked connections in its first turn rather than after the work is underway. * fix(desktop): the free-tier ready screen never interrupts the guided chat A readiness round fires when the layout pick assembles the window, and it raised the free-tier ready screen over the conversation: the user was dropped into the main app, dismissed it, and came back to a card they had already answered. The guide is the introduction. The ready screen now yields while the gate is cinematic, guided or handoff, and the notice is acked the moment the guided chat takes the screen, not only when the film does, so a skipped film no longer leaves it pending. * feat(desktop): tour options that lead to building, and a fork that follows the tour "Just the basics" and "Show me around" read as a click-through with no exit; "I'll figure it out" read as declining help. Now Quick tour, Show me everything, and Skip, let's build something. The script also folds the fork into the same turn as the tour, so when the user closes the overlay the next ask is already waiting instead of a transcript that ends on the tour call. * feat(desktop): the onboarding connector picker reads the live catalog A hardcoded list, however carefully copied from today's catalog, is the next drift. The picker now asks connectors.list through the same session-owned RPC the connector cards use and offers exactly what the gateway carries: a curated lead order puts the everyday apps first, chat channels stay on Messaging, everything else is reachable by search. The picks are gateway slugs, handed straight to manage_connections. No catalog (toolset off, gateway unreachable) ends the step honestly with Skip instead of inventing apps. * test(desktop): the guided first launch never forces a sign-in The acceptance criterion the guided onboarding was built to, as a test: while the gate is cinematic, guided or handoff, the provider picker does not open and a credential warning is dropped rather than deferred to the next send. Outside the guide the picker opens as before. Red against the tree before the guards landed (6 of 9). * fix(desktop): a relaunch mid-guide resumes the guide, in the guide's shape Closing the app during the guided first launch and reopening it booted the normal shell around the persisted solo layout: the connecting splash, the stock composer and model picker, a small window whose sidebars would not open, while the gate still read guided. The gate now queues a kickoff for the guided phase too (the kickoff adopts the existing guide chat by title), takes the solo shape before the gateway opens rather than after, and the connecting overlay yields to the guide's own opening. A typed reply in the composer now closes an ask card and the first-build chips the same way a click does; the layout card's Continue comes back Done. * style(desktop): one answeredAfter helper for the ask card and first-build chips * fix(desktop): the guide takes its shape on the tick the film ends, not after the window shows Between the film and the greeting the full-size shell painted for a beat: finishIntroReveal showed the main window, then the kickoff shrank it once the setup profile answered. The listener on the intro's hidden edge now takes the guide's shape (solo layout + small centred window) synchronously, so the window is already the guide when it is shown. One takeGuideShape owns the pair; kickoff and the boot gate call it idempotently. * style(desktop): the 'nothing connects yet' line reads first on the connectors card
388 lines
16 KiB
Python
388 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 hermes_constants import get_hermes_home
|
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_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 _clean(value: object) -> Optional[str]:
|
|
"""*value* stripped when it is a non-blank string, else None."""
|
|
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
|
|
|
|
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]:
|
|
"""The profile's Nous state, or None. A free-tier identity counts only while the free tier is on:
|
|
with ``nous.guest: false`` it is invisible here, so no cached or refreshed token of it is ever
|
|
attached to a request.
|
|
|
|
Resolves through the same profile-then-global-root fallback every other credential reader
|
|
uses: a profile created with ``share_auth`` has no ``auth.json`` of its own and signs in with
|
|
the root identity. Reading only ``HERMES_HOME/auth.json`` made that profile look signed out to
|
|
the connector gate alone, so ``manage_connections`` vanished from its tool list."""
|
|
try:
|
|
from hermes_cli.auth import get_provider_auth_state
|
|
|
|
nous_provider = get_provider_auth_state("nous")
|
|
if not isinstance(nous_provider, dict):
|
|
return None
|
|
from hermes_cli.anon_auth import guest_enabled, is_guest_state
|
|
|
|
if is_guest_state(nous_provider) and not guest_enabled():
|
|
return None
|
|
return nous_provider
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _parse_timestamp(value: object) -> Optional[datetime]:
|
|
normalized = _clean(value)
|
|
if normalized is None:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(normalized[:-1] + "+00:00" if normalized.endswith("Z") else normalized)
|
|
except ValueError:
|
|
return None
|
|
return (parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)).astimezone(timezone.utc)
|
|
|
|
|
|
def _access_token_is_expiring(expires_at: object, skew_seconds: int) -> bool:
|
|
expires = _parse_timestamp(expires_at)
|
|
return expires is None or (expires - datetime.now(timezone.utc)).total_seconds() <= max(0, int(skew_seconds))
|
|
|
|
|
|
def _read_user_token_override() -> Optional[str]:
|
|
"""Read the TOOL_GATEWAY_USER_TOKEN override through the secret scope. Scope verdict is authoritative
|
|
when installed (a scoped miss must NOT borrow the process env under multiplex); ``os.environ`` only when unscoped."""
|
|
try:
|
|
from agent.secret_scope import UnscopedSecretError, get_secret
|
|
|
|
try:
|
|
explicit = get_secret("TOOL_GATEWAY_USER_TOKEN")
|
|
except UnscopedSecretError:
|
|
explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN")
|
|
except Exception:
|
|
explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN")
|
|
return _clean(explicit)
|
|
|
|
|
|
def peek_nous_access_token() -> Optional[str]:
|
|
"""Cheap token probe: env override or cached auth-store token, no expiry check and no network —
|
|
availability scans must stay off the synchronous OAuth refresh path (:func:`read_nous_access_token`)."""
|
|
return _read_user_token_override() or _clean((_read_nous_provider_state() or {}).get("access_token"))
|
|
|
|
|
|
def read_nous_access_token() -> Optional[str]:
|
|
"""Read a Nous Subscriber OAuth access token from auth store or env override.
|
|
|
|
A read: with no Nous identity there is no bearer and the answer is None. The free-tier identity
|
|
is created by the boot bootstrap (``hermes_cli.free_tier_bootstrap``), never on a token-read
|
|
path (NS-845 Q1.2). A retired free-tier credential IS replaced here, once: that is the explicit
|
|
dead-credential rule, shared with inference.
|
|
"""
|
|
if explicit := _read_user_token_override():
|
|
return explicit
|
|
nous_provider = _read_nous_provider_state() or {}
|
|
if not nous_provider:
|
|
return None
|
|
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
|
|
|
|
if refreshed_token := _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)):
|
|
return refreshed_token
|
|
except Exception as exc:
|
|
# Same dead-credential rule as inference (one place decides it: anon_auth): a retired free-tier
|
|
# identity is replaced once, here, instead of handing back its stale token forever.
|
|
from hermes_cli.anon_auth import AnonCredentialDead
|
|
|
|
if isinstance(exc, AnonCredentialDead):
|
|
return _replace_dead_guest_token(nous_provider)
|
|
logger.debug("Nous access token refresh failed: %s", exc)
|
|
return cached_token
|
|
|
|
|
|
def _replace_dead_guest_token(dead_state: dict) -> Optional[str]:
|
|
from hermes_cli.anon_auth import clear_dead_guest, ensure_portal_identity
|
|
from hermes_cli.auth import resolve_nous_access_token
|
|
|
|
clear_dead_guest("anon_credential_dead", dead_token=dead_state.get("anon_token"))
|
|
try:
|
|
if ensure_portal_identity(explicit=True) is None:
|
|
return None
|
|
return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS))
|
|
except Exception as exc:
|
|
logger.debug("Nous free tier replacement after a retired credential failed: %s", exc)
|
|
return None
|
|
|
|
|
|
def get_tool_gateway_scheme() -> str:
|
|
"""Return configured shared gateway URL scheme."""
|
|
scheme = os.getenv("TOOL_GATEWAY_SCHEME", "").strip().lower() or _DEFAULT_TOOL_GATEWAY_SCHEME
|
|
if scheme not in {"http", "https"}:
|
|
raise ValueError("TOOL_GATEWAY_SCHEME must be 'http' or 'https'")
|
|
return scheme
|
|
|
|
|
|
def build_vendor_gateway_url(vendor: str) -> str:
|
|
"""Return the gateway origin for a specific vendor."""
|
|
if explicit_vendor_url := os.getenv(f"{vendor.upper().replace('-', '_')}_GATEWAY_URL", "").strip().rstrip("/"):
|
|
return explicit_vendor_url
|
|
shared_domain = os.getenv("TOOL_GATEWAY_DOMAIN", "").strip().strip("/") or _DEFAULT_TOOL_GATEWAY_DOMAIN
|
|
return f"{get_tool_gateway_scheme()}://{vendor}-gateway.{shared_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
|
|
gateway_origin = (gateway_builder or build_vendor_gateway_url)(vendor)
|
|
nous_user_token = (token_reader or read_nous_access_token)()
|
|
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:
|
|
"""True when a gateway URL and a likely-usable Nous token are present. Defaults to
|
|
:func:`peek_nous_access_token` (no OAuth refresh); callers about to make a real request use
|
|
:func:`resolve_managed_tool_gateway` instead."""
|
|
return resolve_managed_tool_gateway(vendor, gateway_builder=gateway_builder, token_reader=token_reader or peek_nous_access_token) is not None
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
from urllib.parse import urlsplit # noqa: F401,E402
|
|
from urllib.parse import urlsplit # noqa: F401,E402
|
|
|
|
_MANAGED_GATEWAY_VENDOR = "tool"
|
|
|
|
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()}"}
|
|
|
|
_MEDIA_UPLOAD_PRESIGN_TIMEOUT_SECONDS = 15.0
|
|
|
|
_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
|
|
|
|
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 none resolves.
|
|
|
|
Address resolution only: entitlement is deliberately not consulted here.
|
|
What an account may spend on a managed vendor is the gateway's own
|
|
decision, stated in its refusals, and re-deciding it on the client can only
|
|
ever disagree with the server. A caller that wants to hide its tools from
|
|
users who could not call them at all does that in its ``check_fn``.
|
|
|
|
``None`` means no origin could be resolved — a misconfigured
|
|
``TOOL_GATEWAY_SCHEME`` — so there is nothing to call.
|
|
"""
|
|
builder = gateway_builder or build_vendor_gateway_url
|
|
try:
|
|
origin = builder(_MANAGED_GATEWAY_VENDOR).rstrip("/")
|
|
except ValueError:
|
|
return None
|
|
if not origin:
|
|
return None
|
|
|
|
return {
|
|
"origin": origin,
|
|
"base_url": f"{origin}{managed_vendor_base_path(vendor)}",
|
|
"upload_path": managed_vendor_upload_path(vendor),
|
|
}
|
|
# ---- END PLUGIN-COMPAT ----
|