6efab28726
Custom providers could only authenticate from a static credential (inline api_key or a key_env env var). Enterprise gateways -- SSO/OIDC brokers, cloud IAM, internal auth proxies -- issue short-lived bearers instead, so a value copied into .env is stale within the hour: long sessions start returning 401s and the user has to restart or run an external cron that rewrites .env. The existing `secrets.command` source does not cover this: it runs once per process at startup (subsequent calls are no-ops by design), so it cannot re-mint a credential mid-session. Add providers.<name>.key_cmd: a command that prints a token, wrapped at resolution in a zero-argument callable. Both wire clients already accept a callable api_key and invoke it per request (the Entra ID path established this), so chat_completions, codex_responses and anthropic_messages all work unchanged and always send a fresh credential. The callable also routes the Anthropic client through its per-request Authorization hook, which is what OAuth-gated gateway routes require -- so no per-vendor auth wiring is needed anywhere in core. - cached until shortly before the advertised expiry (60s leeway), so the helper runs about once per token lifetime rather than once per request - expiry is read from the OAuth 2.0 relative `expires_in` when present, and otherwise from an absolute ISO 8601 deadline (`expiry`, `expiresOn`), which is what CLI token helpers commonly print. Reading only `expires_in` treated those helpers as advertising no TTL at all, cached their token for the life of the process, and returned 401 on every request once the real deadline passed. ISO parsing reuses hermes_cli.auth._parse_iso_timestamp rather than adding another datetime parser. - no synthetic expiry: when no TTL is advertised, or the advertised one is unparseable or already past, the token is used and refreshed on 401 instead of re-minted on an invented schedule - stdout contract matches OAuth 2.0 token endpoints and existing agent helpers (bare token or JSON access_token/expires_in); multi-line output is rejected rather than guessed at, so a misconfigured helper surfaces as a clear error instead of a corrupt-credential 401 - precedence: explicit --api-key still wins; otherwise key_cmd beats a static api_key/key_env on the same entry - failures never include the helper's output (may hold a partial token) or the command string (may embed a client secret) Resolution happens on two paths. agent/auxiliary_client.py resolves named custom providers itself rather than calling _resolve_named_custom_runtime, so key_cmd is honoured in both: wiring only the runtime resolver leaves the main agent turn working while every auxiliary call (title generation, compression, vision, embedding) falls back to the no-key-required placeholder and 401s. Precedence is identical on both paths, so one config entry cannot yield two different credentials depending on which resolver the caller reached. Closes #84162 Signed-off-by: LordMelkor <kray@block.xyz>
187 lines
7.7 KiB
Python
187 lines
7.7 KiB
Python
"""Mint a provider API key by running a command (``key_cmd``).
|
|
|
|
Static API keys are the exception at enterprise gateways: SSO/OIDC brokers,
|
|
cloud IAM, and internal auth proxies all issue SHORT-LIVED bearers instead.
|
|
A key copied into ``.env`` (``key_env``) is stale within the hour, so every
|
|
request after that 401s and the user has to restart the session.
|
|
|
|
``key_cmd`` names a command that PRINTS a token, so the credential is derived
|
|
rather than stored::
|
|
|
|
providers:
|
|
my-gateway:
|
|
base_url: https://gateway.internal.example.com/v1
|
|
api_mode: chat_completions
|
|
key_cmd: my-auth-cli print-token --profile prod
|
|
|
|
This is the established pattern for agent tooling — Claude Code's
|
|
``apiKeyHelper``, the ``gcloud auth print-access-token`` / ``aws ecr
|
|
get-login-password`` idiom, and vendor helpers such as ``databricks auth
|
|
token`` all expose exactly this contract. Hermes already accepts a callable
|
|
API key on both wire clients (the Entra ID / Azure identity path) and invokes
|
|
it per request, so nothing downstream changes: the token is simply always
|
|
fresh. It is cached until shortly before expiry, so the command runs about
|
|
once per token lifetime rather than once per request.
|
|
|
|
Output contract: print ONLY the token on stdout, either bare or as JSON with
|
|
an ``access_token`` field (``expires_in`` is honoured when present) — the
|
|
shape OAuth 2.0 token endpoints and the helpers above already emit.
|
|
|
|
Precedence: an explicit ``--api-key`` still wins (the one-off recovery escape
|
|
hatch); otherwise ``key_cmd`` is preferred over a static ``api_key`` /
|
|
``key_env`` on the same entry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from typing import Callable, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Treat a cached token as spent slightly before its stated expiry, so a request
|
|
# can't be signed with a token that dies in flight. 60s matches the leeway used
|
|
# by comparable OAuth token caches.
|
|
_TOKEN_REFRESH_LEEWAY_SECONDS = 60.0
|
|
# A token helper reads a local credential cache and should answer in
|
|
# milliseconds; anything approaching this budget is hung, not slow.
|
|
_MINT_TIMEOUT_SECONDS = 15
|
|
|
|
|
|
class CommandTokenError(RuntimeError):
|
|
"""A ``key_cmd`` failed to produce a usable token."""
|
|
|
|
|
|
def _mint(command: str, label: str) -> tuple[str, Optional[float]]:
|
|
"""Run *command*, returning ``(token, ttl_seconds_or_None)``."""
|
|
try:
|
|
completed = subprocess.run(
|
|
command,
|
|
shell=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_MINT_TIMEOUT_SECONDS,
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise CommandTokenError(
|
|
f"key_cmd for provider {label!r} timed out after "
|
|
f"{_MINT_TIMEOUT_SECONDS}s"
|
|
) from exc
|
|
except OSError as exc:
|
|
raise CommandTokenError(
|
|
f"key_cmd for provider {label!r} could not be executed: {exc}"
|
|
) from exc
|
|
|
|
if completed.returncode != 0:
|
|
# NEVER include stdout/stderr: a partially-successful auth helper can
|
|
# print a token or refresh secret there. The command STRING is also
|
|
# withheld — a key_cmd can legitimately embed a secret
|
|
# (`print-token --client-secret=…`), so echoing it back would leak the
|
|
# very credential this module exists to protect. Name the provider so
|
|
# the user knows which config entry to run by hand.
|
|
raise CommandTokenError(
|
|
f"key_cmd for provider {label!r} exited {completed.returncode}. "
|
|
f"Run that provider's key_cmd manually to see why "
|
|
f"(e.g. `databricks auth login` if its OAuth session expired)."
|
|
)
|
|
|
|
stdout = completed.stdout or ""
|
|
if not stdout.strip():
|
|
raise CommandTokenError(f"key_cmd for provider {label!r} produced no output")
|
|
|
|
# JSON payload — the shape `databricks auth token --output json` prints.
|
|
# Token extraction mirrors databricks/ucode's get_databricks_token:
|
|
# json.loads(result.stdout or "{}").get("access_token", "")
|
|
if stdout.lstrip().startswith("{"):
|
|
try:
|
|
payload = json.loads(stdout)
|
|
except json.JSONDecodeError:
|
|
payload = None
|
|
if isinstance(payload, dict):
|
|
token = str(payload.get("access_token") or "").strip()
|
|
if not token:
|
|
raise CommandTokenError(
|
|
f"key_cmd for provider {label!r} returned JSON without an "
|
|
"'access_token' field"
|
|
)
|
|
ttl = payload.get("expires_in")
|
|
if isinstance(ttl, (int, float)) and ttl > 0:
|
|
return token, float(ttl)
|
|
# A relative lifetime is the OAuth 2.0 field, but CLI token helpers
|
|
# commonly print an absolute ISO 8601 deadline instead. Treating
|
|
# that as "no TTL advertised" caches the token for the life of the
|
|
# process, so every request 401s once the deadline passes.
|
|
# Imported lazily: hermes_cli.auth imports from agent.* at module
|
|
# level, so a top-level import here would risk a cycle.
|
|
from hermes_cli.auth import _parse_iso_timestamp
|
|
|
|
for field in ("expiry", "expiresOn"):
|
|
deadline = _parse_iso_timestamp(payload.get(field))
|
|
if deadline is not None:
|
|
remaining = deadline - time.time()
|
|
if remaining > 0:
|
|
return token, remaining
|
|
return token, None
|
|
|
|
# Bare token. The contract every comparable helper documents is "stdout
|
|
# carries the token and nothing else" — extra output would be consumed as
|
|
# part of the credential. Strip surrounding whitespace and take the rest
|
|
# verbatim; do NOT silently keep one line of several, which converts a
|
|
# misconfigured helper (banner, warning, two tokens) into a corrupt-key 401
|
|
# that is far harder to diagnose than an explicit refusal.
|
|
token = stdout.strip()
|
|
if "\n" in token:
|
|
raise CommandTokenError(
|
|
f"key_cmd for provider {label!r} printed multiple lines; it must "
|
|
"print only the token (or JSON with an 'access_token' field)"
|
|
)
|
|
return token, None
|
|
|
|
|
|
class CommandTokenSource:
|
|
"""Callable returning a bearer token, cached until shortly before expiry."""
|
|
|
|
def __init__(self, command: str, label: str = "custom") -> None:
|
|
self._command = command
|
|
self._label = label or "custom"
|
|
self._lock = threading.Lock()
|
|
self._token = ""
|
|
self._expires_at: Optional[float] = None
|
|
|
|
def __call__(self) -> str:
|
|
with self._lock:
|
|
# ``expires_at is None`` means the command advertised no TTL: use
|
|
# the token and rely on the caller's 401 handling, rather than
|
|
# inventing an expiry. Same contract as buzz's is_expired().
|
|
if self._token and (
|
|
self._expires_at is None or time.monotonic() < self._expires_at
|
|
):
|
|
return self._token
|
|
token, ttl = _mint(self._command, self._label)
|
|
self._token = token
|
|
self._expires_at = (
|
|
time.monotonic() + max(ttl - _TOKEN_REFRESH_LEEWAY_SECONDS, 5.0)
|
|
if ttl
|
|
else None
|
|
)
|
|
logger.debug(
|
|
"Minted key_cmd token for provider %s (ttl=%s)",
|
|
self._label, f"{int(ttl)}s" if ttl else "unknown",
|
|
)
|
|
return token
|
|
|
|
|
|
def build_command_token_provider(
|
|
key_cmd: str,
|
|
provider_label: str = "custom",
|
|
) -> Optional[Callable[[], str]]:
|
|
"""A per-request token provider for *key_cmd*, or ``None`` when unset."""
|
|
command = str(key_cmd or "").strip()
|
|
if not command:
|
|
return None
|
|
return CommandTokenSource(command, provider_label)
|