Files
hermes-agent/agent/command_token_source.py
T
LordMelkor 6efab28726 feat(auth): add key_cmd credential source for custom providers
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>
2026-08-15 03:16:21 -07:00

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)