mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
/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
154 lines
6.6 KiB
Python
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
|