Files
hermes-agent/agent/command_token_source.py
Teknium e7a81e7520 fix(cli): label a callable API key by its credential source, not by "is it callable"
/config (cli.py show_config) and the agent's key banner (_print_key_banner)
printed "Microsoft Entra ID" for every callable api_key, so a custom
provider's key_cmd bearer or MiniMax OAuth read as Azure. /config also
picked Vertex out with a __module__ string compare, and the banner had no
Vertex case at all.

Each builder now tags the callable it returns with a source
(agent/credential_source.py): build_token_provider -> entra-id,
vertex_access_token -> vertex-oauth, CommandTokenSource -> key-cmd,
build_minimax_oauth_token_provider -> minimax-oauth. Both surfaces resolve
the label through one source-to-label table. An untagged callable gets the
neutral "Token provider" label. The tag is read with getattr, so no display
path calls the callable (no token minted just to mask it).

New catalog keys key_cmd_token, minimax_oauth and token_provider in every
locale (English text, matching the brand-name convention of the existing
microsoft_entra_id / google_vertex_oauth keys).

Tests: tests/agent/test_credential_source_labels.py, one red/green case per
label on each surface (7 red before: key_cmd/minimax/unknown on /config,
vertex/key_cmd/minimax/unknown on the banner). Each case's callable raises
if invoked. The old "any other callable is Entra" pin now uses a real
Entra-tagged provider.

Review finding by @unsupportedpastels on #140469
Review finding by @alt-glitch on #140469
Refs #134889 (T6)
Refs #140337
2026-10-17 07:23:06 -07:00

154 lines
6.6 KiB
Python

"""Mint a provider API key by running a command (``key_cmd``).
Enterprise gateways (SSO/OIDC brokers, cloud IAM, auth proxies) issue SHORT-LIVED bearers; a key
copied into ``.env`` goes stale within the hour. ``key_cmd`` names a command that PRINTS a token
(the ``apiKeyHelper`` / ``gcloud auth print-access-token`` idiom). Both wire clients accept a
callable API key and invoke it per request; the token is cached until shortly before expiry.
Output contract: ONLY the token on stdout, bare or as JSON with an ``access_token`` field
(``expires_in`` / ISO ``expiry`` honoured). Precedence: explicit ``--api-key`` wins (one-off
recovery escape hatch); otherwise ``key_cmd`` beats a static ``api_key`` / ``key_env``.
"""
from __future__ import annotations
import json
import logging
import subprocess
import threading
import time
from typing import Optional
from agent.credential_source import KEY_CMD
logger = logging.getLogger(__name__)
# Treat a token as spent slightly before expiry so a request can't be signed with one that dies in
# flight (60s = usual OAuth cache leeway).
_TOKEN_REFRESH_LEEWAY_SECONDS = 60.0
# Helpers answer from a local cache in milliseconds; this long means hung.
_MINT_TIMEOUT_SECONDS = 15
# No advertised expiry: nothing in the request path re-mints on 401 (the SDK retries 429/5xx only), so
# a process-lifetime cache would 401 forever once the token died. Re-mint on a bounded window instead.
_NO_TTL_REFRESH_SECONDS = 900.0
class CommandTokenError(RuntimeError):
"""A ``key_cmd`` failed to produce a usable token."""
def materialize_probe_api_key(api_key: object) -> str:
"""Best-effort probe credential; never send a callable's repr or log mint errors."""
try:
token = api_key() if callable(api_key) else api_key
except Exception:
return ""
return token.strip() if isinstance(token, str) else ""
def _mint(command: str, label: str) -> tuple[str, Optional[float]]:
"""Run *command*, returning ``(token, ttl_seconds_or_None)``. The helper runs FOR the profile whose
provider is being minted: it gets that profile's own env (secrets + HERMES_HOME), never the multiplexer's
launch environ — an ``op read`` / ``vault kv get`` helper must sign in as the served profile."""
from tools.environments.local import served_profile_child_env
try:
completed = subprocess.run(
command, stdin=subprocess.DEVNULL, shell=True, capture_output=True, text=True, errors="replace", timeout=_MINT_TIMEOUT_SECONDS,
env=served_profile_child_env(inherit_credentials=True),
check=False,
)
except subprocess.TimeoutExpired as exc:
raise CommandTokenError(
f"key_cmd for provider {label!r} timed out after {_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 (may hold a token) or the command string (may embed
# `--client-secret=…`); name the provider instead.
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.
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)
# CLI helpers often print an absolute ISO 8601 deadline instead of OAuth's relative
# lifetime; honour it or the token 401s once past. Lazy import: hermes_cli.auth imports agent.*.
from hermes_cli.auth import _parse_iso_timestamp
for field in ("expiry", "expiresOn"):
deadline = _parse_iso_timestamp(payload.get(field))
remaining = deadline - time.time() if deadline is not None else 0
if remaining > 0:
return token, remaining
return token, None
# Bare token: stdout carries the token and nothing else. Do NOT keep one line of several — that
# turns a misconfigured helper (banner, warning) into a corrupt-key 401 far harder to diagnose.
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."""
hermes_credential_source = KEY_CMD # display label source; read without calling (agent.credential_source)
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: float = 0.0
@property
def cache_identity(self) -> str:
"""Stable catalog identity; token rotation must not mint on cache reads."""
return f"cmd:{self._command}"
def __call__(self) -> str:
with self._lock:
if self._token and 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 _NO_TTL_REFRESH_SECONDS
)
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[CommandTokenSource]:
"""A per-request token provider for *key_cmd*, or ``None`` when unset."""
command = str(key_cmd or "").strip()
return CommandTokenSource(command, provider_label) if command else None