- anthropic_credentials: _load_json_if_exists / _claude_oauth_record / _atomic_write_private_json / _post_oauth_token / _oauth_token_state shared by the Claude Code + hermes_pkce read/write/refresh/exchange paths; _fingerprint delegates to credential_persistence.fingerprint_secret_value. - rate_limit_tracker: lower_headers / has_rate_limit_headers exported and reused by nous_rate_guard (header parsing, bucket exhaustion via _is_exhausted); format_remaining = _fmt_seconds alias (public name kept). - _safe_int stays tolerant of non-finite values (parity with origin). - Docstring/comment compaction; every WHY kept. - Parity: /tmp/rf/creds_c_parity_probe.py output identical to origin baseline (except wall-clock 'captured N ago' text); refresh_anthropic_oauth_pure old-vs-new on 6 token payloads identical.
783 lines
31 KiB
Python
783 lines
31 KiB
Python
"""Anthropic credential sources, OAuth flows, and token resolution.
|
|
|
|
Sources, in the order ``resolve_anthropic_token()`` consults them:
|
|
|
|
1. ``ANTHROPIC_TOKEN`` / ``CLAUDE_CODE_OAUTH_TOKEN`` (explicit OAuth env)
|
|
2. ``ANTHROPIC_API_KEY`` (explicit API key)
|
|
3. ``~/.claude/.credentials.json`` / macOS Keychain (Claude Code)
|
|
4. the credential pool in ``auth.json``
|
|
|
|
``~/.hermes/.anthropic_oauth.json`` (Hermes PKCE login) and the Claude Code
|
|
file are *singletons*: ``credential_pool._seed_from_singletons()`` re-reads
|
|
them on every ``load_pool()``, so a failed write here is a failed refresh
|
|
(``CredentialPersistError``), not a cache miss. ``agent.anthropic_adapter``
|
|
re-exports every public name below.
|
|
"""
|
|
|
|
import base64
|
|
import contextlib
|
|
import functools
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import secrets
|
|
import stat
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from collections import OrderedDict
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
from hermes_constants import get_hermes_home
|
|
from agent.secret_scope import get_secret as _get_secret
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
# platform.claude.com is the live token host; console.anthropic.com 404s but
|
|
# is kept as a fallback. _OAUTH_TOKEN_URL stays for backward-compatible imports.
|
|
_OAUTH_TOKEN_URLS = [
|
|
"https://platform.claude.com/v1/oauth/token",
|
|
"https://console.anthropic.com/v1/oauth/token",
|
|
]
|
|
_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
|
|
# Token-endpoint UA. Anthropic 429s any token-endpoint request whose UA starts
|
|
# with ``claude-code/`` (or Mozilla); the real CLI uses bare axios there. The
|
|
# inference path (build_anthropic_kwargs) still needs the claude-code/ UA.
|
|
_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
|
|
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
|
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
|
|
|
|
|
|
def _getenv(name: str, default: str = "") -> str:
|
|
"""Profile-scoped os.getenv for credential reads (fail-closed on unscoped reads when multiplexing).
|
|
|
|
Mirrors the same wrapper in hermes_cli/runtime_provider.py.
|
|
"""
|
|
val = _get_secret(name, default)
|
|
return val if val is not None else default
|
|
|
|
|
|
def _is_oauth_token(key: str) -> bool:
|
|
"""True for Anthropic OAuth/setup tokens; False for Console API keys and non-Anthropic keys.
|
|
|
|
sk-ant-api* = Console API key (x-api-key auth, never OAuth). sk-ant-* = setup
|
|
tokens / managed keys, eyJ = OAuth JWTs, cc- = CLAUDE_CODE_OAUTH_TOKEN access tokens.
|
|
"""
|
|
if not key or key.startswith("sk-ant-api"):
|
|
return False
|
|
return key.startswith(("sk-ant-", "eyJ", "cc-"))
|
|
|
|
|
|
class CredentialPersistError(RuntimeError):
|
|
"""A rotated single-use credential could not be durably committed.
|
|
|
|
The refresh POST already spent the old refresh token, so a swallowed write
|
|
failure leaves a consumed pair on disk that later replays as invalid_grant.
|
|
Callers must treat this as a failed refresh.
|
|
"""
|
|
|
|
def __init__(self, path: Any, cause: BaseException) -> None:
|
|
super().__init__(
|
|
f"failed to durably persist rotated Anthropic credentials to {path}: {cause}"
|
|
)
|
|
self.path = path
|
|
|
|
|
|
# ── Spent-rotation registry ─────────────────────────────────────────────
|
|
# Fingerprints of secrets whose refresh POST succeeded but whose replacement
|
|
# never reached its store. Two scopes: process-local (this OrderedDict) and a
|
|
# durable sidecar next to the shared singleton file so OTHER processes sharing
|
|
# it also fail closed. Non-reversible digests only; never cleared (spent is spent).
|
|
_SPENT_ROTATION_LOCK = threading.Lock()
|
|
_SPENT_ROTATION_FINGERPRINTS: "OrderedDict[str, None]" = OrderedDict()
|
|
_SPENT_ROTATION_MAX_TRACKED = 64
|
|
_SPENT_ROTATION_SIDECAR_VERSION = 1
|
|
|
|
|
|
def _spent_rotation_sidecar_path(source_path: Path) -> Path:
|
|
return source_path.with_name(source_path.name + ".hermes-spent-rotations.json")
|
|
|
|
|
|
def spent_rotation_source_path(source: Any) -> Optional[Path]:
|
|
"""Map a pool-entry source to the shared singleton file it borrows from (or None)."""
|
|
getter = _SINGLETON_SOURCE_PATHS.get(source) if isinstance(source, str) else None
|
|
return getter() if getter else None
|
|
|
|
|
|
def _read_spent_rotation_sidecar(source_path: Optional[Path]) -> set:
|
|
if source_path is None:
|
|
return set()
|
|
try:
|
|
raw = json.loads(_spent_rotation_sidecar_path(source_path).read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return set()
|
|
fingerprints = raw.get("fingerprints") if isinstance(raw, dict) else None
|
|
if not isinstance(fingerprints, list):
|
|
return set()
|
|
return {fp for fp in fingerprints if isinstance(fp, str) and fp}
|
|
|
|
|
|
def _append_spent_rotation_sidecar(source_path: Path, fingerprints: list) -> None:
|
|
"""Merge fingerprints into the sidecar (atomic replace; caller holds the path lock).
|
|
|
|
Fail-soft: a sidecar write failure must never mask the process-local verdict.
|
|
"""
|
|
sidecar = _spent_rotation_sidecar_path(source_path)
|
|
try:
|
|
merged = _read_spent_rotation_sidecar(source_path)
|
|
merged.update(fingerprints)
|
|
bounded = sorted(merged)[-_SPENT_ROTATION_MAX_TRACKED * 4 :]
|
|
payload = json.dumps(
|
|
{
|
|
"version": _SPENT_ROTATION_SIDECAR_VERSION,
|
|
"comment": (
|
|
"Non-secret one-way fingerprints of Anthropic OAuth "
|
|
"credentials whose rotation was consumed server-side but "
|
|
"never durably committed. Written by Hermes so sibling "
|
|
"processes sharing this credential source fail closed "
|
|
"instead of replaying a spent single-use refresh token."
|
|
),
|
|
"fingerprints": bounded,
|
|
},
|
|
indent=2,
|
|
)
|
|
sidecar.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = sidecar.with_name(sidecar.name + ".tmp")
|
|
tmp.write_text(payload, encoding="utf-8")
|
|
os.replace(tmp, sidecar)
|
|
except Exception:
|
|
logger.debug("Failed to persist spent-rotation fingerprints to %s", sidecar, exc_info=True)
|
|
|
|
|
|
def _fingerprint(secret: Any) -> Optional[str]:
|
|
from agent.credential_persistence import fingerprint_secret_value
|
|
|
|
value = str(secret or "").strip()
|
|
return fingerprint_secret_value(value) if value else None
|
|
|
|
|
|
def mark_rotation_consumed_uncommitted(*secrets: Any, source_path: Optional[Path] = None) -> None:
|
|
"""Record the pre-rotation pair of a refresh whose replacement never committed.
|
|
|
|
With ``source_path`` the verdict is also persisted to that singleton's sidecar.
|
|
"""
|
|
recorded: list = []
|
|
with _SPENT_ROTATION_LOCK:
|
|
for secret in secrets:
|
|
fingerprint = _fingerprint(secret)
|
|
if not fingerprint:
|
|
continue
|
|
recorded.append(fingerprint)
|
|
_SPENT_ROTATION_FINGERPRINTS.pop(fingerprint, None)
|
|
_SPENT_ROTATION_FINGERPRINTS[fingerprint] = None
|
|
while len(_SPENT_ROTATION_FINGERPRINTS) > _SPENT_ROTATION_MAX_TRACKED:
|
|
_SPENT_ROTATION_FINGERPRINTS.popitem(last=False)
|
|
if recorded and source_path is not None:
|
|
_append_spent_rotation_sidecar(source_path, recorded)
|
|
|
|
|
|
def is_rotation_consumed_uncommitted(secret: Any, *, source_path: Optional[Path] = None) -> bool:
|
|
"""True when *secret* belongs to a rotation that was spent but not committed."""
|
|
fingerprint = _fingerprint(secret)
|
|
if not fingerprint:
|
|
return False
|
|
with _SPENT_ROTATION_LOCK:
|
|
if fingerprint in _SPENT_ROTATION_FINGERPRINTS:
|
|
return True
|
|
return fingerprint in _read_spent_rotation_sidecar(source_path)
|
|
|
|
|
|
# ── Claude Code credentials (Keychain / ~/.claude/.credentials.json) ────
|
|
# Only singleton-backed pool sources have a cross-process authority boundary.
|
|
_SINGLETON_SOURCE_PATHS = {
|
|
"claude_code": lambda: claude_code_credentials_path(),
|
|
"hermes_pkce": lambda: _get_hermes_oauth_file(),
|
|
}
|
|
|
|
|
|
def _claude_oauth_record(data: Any, source: str) -> Optional[Dict[str, Any]]:
|
|
"""Normalise a ``{"claudeAiOauth": {...}}`` payload into our credential dict."""
|
|
oauth_data = data.get("claudeAiOauth")
|
|
if not (oauth_data and isinstance(oauth_data, dict)):
|
|
return None
|
|
access_token = oauth_data.get("accessToken", "")
|
|
if not access_token:
|
|
return None
|
|
return {
|
|
"accessToken": access_token,
|
|
"refreshToken": oauth_data.get("refreshToken", ""),
|
|
"expiresAt": oauth_data.get("expiresAt", 0),
|
|
"source": source,
|
|
}
|
|
|
|
|
|
def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
|
|
"""Read the "Claude Code-credentials" macOS Keychain entry (Claude Code >=2.1.114)."""
|
|
if platform.system() != "Darwin":
|
|
return None
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
capture_output=True, text=True, encoding='utf-8', errors='replace',
|
|
timeout=5, stdin=subprocess.DEVNULL,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
logger.debug("Keychain: security command not available or timed out")
|
|
return None
|
|
if result.returncode != 0:
|
|
logger.debug("Keychain: no entry found for 'Claude Code-credentials'")
|
|
return None
|
|
raw = result.stdout.strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
logger.debug("Keychain: credentials payload is not valid JSON")
|
|
return None
|
|
return _claude_oauth_record(data, "macos_keychain")
|
|
|
|
|
|
def claude_code_credentials_path() -> Path:
|
|
"""Claude Code's shared OAuth file; every profile reads/writes this same path."""
|
|
return Path.home() / ".claude" / ".credentials.json"
|
|
|
|
|
|
def _load_json_if_exists(path: Path, what: str) -> Optional[Any]:
|
|
"""Parsed JSON from *path*, or None when missing/unreadable/corrupt (debug-logged)."""
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.debug("Failed to read %s: %s", what, e)
|
|
return None
|
|
|
|
|
|
def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]:
|
|
data = _load_json_if_exists(claude_code_credentials_path(), "~/.claude/.credentials.json")
|
|
return _claude_oauth_record(data, "claude_code_credentials_file") if data is not None else None
|
|
|
|
|
|
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
|
"""Read refreshable Claude Code OAuth credentials (Keychain and/or file).
|
|
|
|
When both exist: prefer the only non-expired one (Claude Code 2.1.x refreshes
|
|
one source but not the other), else the later ``expiresAt`` so a refresh uses
|
|
the freshest refreshToken. ~/.claude.json primaryApiKey is deliberately excluded.
|
|
"""
|
|
kc_creds = _read_claude_code_credentials_from_keychain()
|
|
file_creds = _read_claude_code_credentials_from_file()
|
|
|
|
if not (kc_creds and file_creds):
|
|
return kc_creds or file_creds
|
|
kc_valid = is_claude_code_token_valid(kc_creds)
|
|
file_valid = is_claude_code_token_valid(file_creds)
|
|
if kc_valid != file_valid:
|
|
return kc_creds if kc_valid else file_creds
|
|
kc_exp = kc_creds.get("expiresAt", 0) or 0
|
|
file_exp = file_creds.get("expiresAt", 0) or 0
|
|
return kc_creds if kc_exp >= file_exp else file_creds
|
|
|
|
|
|
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
|
|
"""Non-expired access token (60s buffer); no expiresAt means managed key → valid if present."""
|
|
expires_at = creds.get("expiresAt", 0)
|
|
if not expires_at:
|
|
return bool(creds.get("accessToken"))
|
|
return int(time.time() * 1000) < (expires_at - 60_000)
|
|
|
|
|
|
# ── OAuth token endpoint ────────────────────────────────────────────────
|
|
|
|
|
|
def _post_oauth_token(
|
|
data: bytes, *, content_type: str, timeout: int, what: str, user_agent: str = _OAUTH_TOKEN_USER_AGENT
|
|
) -> Dict[str, Any]:
|
|
"""POST to the token endpoints in order; raise the last error if all fail."""
|
|
import urllib.request
|
|
|
|
last_error = None
|
|
for endpoint in _OAUTH_TOKEN_URLS:
|
|
req = urllib.request.Request(
|
|
endpoint, data=data, method="POST",
|
|
headers={"Content-Type": content_type, "User-Agent": user_agent},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode())
|
|
except Exception as exc:
|
|
last_error = exc
|
|
logger.debug("Anthropic token %s failed at %s: %s", what, endpoint, exc)
|
|
if last_error is not None:
|
|
raise last_error
|
|
raise ValueError(f"Anthropic token {what} failed")
|
|
|
|
|
|
def _oauth_token_state(result: Dict[str, Any], *, fallback_refresh_token: str = "") -> Dict[str, Any]:
|
|
"""Token-endpoint JSON -> ``{access_token, refresh_token, expires_at_ms}`` (expires_in defaults to 3600s)."""
|
|
expires_in = result.get("expires_in", 3600)
|
|
return {
|
|
"access_token": result.get("access_token", ""),
|
|
"refresh_token": result.get("refresh_token", fallback_refresh_token),
|
|
"expires_at_ms": int(time.time() * 1000) + (expires_in * 1000),
|
|
}
|
|
|
|
|
|
def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False) -> Dict[str, Any]:
|
|
"""Refresh an Anthropic OAuth token without mutating local credential files."""
|
|
import urllib.parse
|
|
|
|
if not refresh_token:
|
|
raise ValueError("refresh_token is required")
|
|
|
|
payload = {
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refresh_token,
|
|
"client_id": _OAUTH_CLIENT_ID,
|
|
}
|
|
if use_json:
|
|
data, content_type = json.dumps(payload).encode(), "application/json"
|
|
else:
|
|
data, content_type = urllib.parse.urlencode(payload).encode(), "application/x-www-form-urlencoded"
|
|
result = _post_oauth_token(
|
|
data, content_type=content_type, timeout=10, what="refresh", user_agent=_OAUTH_TOKEN_USER_AGENT
|
|
)
|
|
if not result.get("access_token"):
|
|
raise ValueError("Anthropic refresh response was missing access_token")
|
|
return _oauth_token_state(result, fallback_refresh_token=refresh_token)
|
|
|
|
|
|
def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
|
|
"""Refresh an expired Claude Code OAuth token, returning the new access token.
|
|
|
|
Refresh tokens are single-use and Claude Code refreshes on its own schedule,
|
|
so we first re-read the live sources and adopt an already-rotated token
|
|
instead of racing it into ``invalid_grant``. Read, decision, POST and
|
|
write-back all run under the same path-keyed cross-process lock the pool
|
|
uses; without it two profiles can still spend one refresh token.
|
|
"""
|
|
try:
|
|
from hermes_cli.auth import AUTH_LOCK_TIMEOUT_SECONDS, _auth_store_lock, env_float
|
|
|
|
refresh_timeout_seconds = env_float("HERMES_ANTHROPIC_REFRESH_TIMEOUT_SECONDS", 20)
|
|
lock_timeout_seconds = max(float(AUTH_LOCK_TIMEOUT_SECONDS), float(refresh_timeout_seconds) + 5.0)
|
|
with _auth_store_lock(timeout_seconds=lock_timeout_seconds, target_path=claude_code_credentials_path()):
|
|
# Adopt only a DIFFERENT token with a real future expiry (0/absent
|
|
# expiresAt means managed key / unknown expiry).
|
|
current = read_claude_code_credentials() or {}
|
|
current_token = current.get("accessToken", "")
|
|
if (
|
|
current_token
|
|
and current_token != creds.get("accessToken", "")
|
|
and (current.get("expiresAt", 0) or 0) > 0
|
|
and is_claude_code_token_valid(current)
|
|
):
|
|
logger.debug("Adopted Claude Code's already-refreshed OAuth token")
|
|
return current_token
|
|
|
|
refresh_token = current.get("refreshToken", "") or creds.get("refreshToken", "")
|
|
if not refresh_token:
|
|
logger.debug("No refresh token available — cannot refresh")
|
|
return None
|
|
|
|
# Another process may have spent this token and lost the commit;
|
|
# its sidecar verdict is authoritative for the shared source.
|
|
if is_rotation_consumed_uncommitted(
|
|
refresh_token, source_path=claude_code_credentials_path()
|
|
):
|
|
logger.debug(
|
|
"Refresh token was already consumed by an uncommitted rotation "
|
|
"- refusing to replay it; re-run 'claude setup-token'"
|
|
)
|
|
return None
|
|
|
|
try:
|
|
refreshed = refresh_anthropic_oauth_pure(refresh_token, use_json=False)
|
|
except Exception as e:
|
|
logger.debug("Failed to refresh Claude Code token: %s", e)
|
|
return None
|
|
|
|
# The POST spent ``refresh_token``; this write is the commit step.
|
|
# On failure, fail closed and mark the pre-rotation pair as spent.
|
|
try:
|
|
_write_claude_code_credentials(
|
|
refreshed["access_token"],
|
|
refreshed["refresh_token"],
|
|
refreshed["expires_at_ms"],
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
"Anthropic OAuth refresh rotated the single-use token but could not "
|
|
"commit it to %s (%s) — treating the refresh as failed; "
|
|
"re-run 'claude setup-token' to reauthenticate",
|
|
claude_code_credentials_path(),
|
|
e,
|
|
)
|
|
mark_rotation_consumed_uncommitted(
|
|
refresh_token,
|
|
creds.get("accessToken", ""),
|
|
current.get("accessToken", ""),
|
|
current.get("refreshToken", ""),
|
|
source_path=claude_code_credentials_path(),
|
|
)
|
|
return None
|
|
|
|
logger.debug("Successfully refreshed Claude Code OAuth token")
|
|
return refreshed["access_token"]
|
|
except Exception as e:
|
|
# Lock/read failures keep the resolver's fail-soft contract.
|
|
logger.debug("Failed to acquire Claude Code refresh lock: %s", e)
|
|
return None
|
|
|
|
|
|
def _atomic_write_private_json(path: Path, payload: Any) -> None:
|
|
"""Write *payload* to *path* via a 0o600 temp file + fsync + os.replace.
|
|
|
|
The temp file is created O_EXCL at 0o600 so the token is never briefly
|
|
umask-readable (write_text + chmod had a TOCTOU window); the per-process
|
|
random suffix avoids collisions with concurrent writers and crashed leftovers.
|
|
The parent dir's mode is left alone (~/.claude/ is owned by Claude Code).
|
|
"""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
|
|
try:
|
|
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
json.dump(payload, fh, indent=2)
|
|
fh.flush()
|
|
os.fsync(fh.fileno())
|
|
os.replace(tmp, path)
|
|
except OSError:
|
|
with contextlib.suppress(OSError):
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def _write_claude_code_credentials(
|
|
access_token: str,
|
|
refresh_token: str,
|
|
expires_at_ms: int,
|
|
*,
|
|
scopes: Optional[list] = None,
|
|
) -> None:
|
|
"""Commit refreshed credentials to ~/.claude/.credentials.json.
|
|
|
|
*scopes* (or the previously stored scopes) are persisted because Claude Code
|
|
>=2.1.81 gates on ``"user:inference"`` being present. Raises
|
|
``CredentialPersistError`` on any failure, including a corrupt existing file.
|
|
"""
|
|
cred_path = claude_code_credentials_path()
|
|
try:
|
|
existing = json.loads(cred_path.read_text(encoding="utf-8")) if cred_path.exists() else {}
|
|
|
|
oauth_data: Dict[str, Any] = {
|
|
"accessToken": access_token,
|
|
"refreshToken": refresh_token,
|
|
"expiresAt": expires_at_ms,
|
|
}
|
|
if scopes is not None:
|
|
oauth_data["scopes"] = scopes
|
|
elif "claudeAiOauth" in existing and "scopes" in existing["claudeAiOauth"]:
|
|
oauth_data["scopes"] = existing["claudeAiOauth"]["scopes"]
|
|
|
|
existing["claudeAiOauth"] = oauth_data
|
|
_atomic_write_private_json(cred_path, existing)
|
|
except (OSError, ValueError) as e:
|
|
logger.error("Failed to write refreshed credentials to %s: %s", cred_path, e)
|
|
raise CredentialPersistError(cred_path, e) from e
|
|
|
|
|
|
# ── Resolution ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
|
"""Resolve a token from Claude Code credential files, refreshing if needed."""
|
|
creds = creds or read_claude_code_credentials()
|
|
if creds and is_rotation_consumed_uncommitted(
|
|
creds.get("accessToken", ""), source_path=claude_code_credentials_path()
|
|
):
|
|
# The file still holds the spent pre-rotation copy of a failed commit.
|
|
logger.debug(
|
|
"Claude Code credentials hold a rotated-but-uncommitted token - refusing"
|
|
)
|
|
return None
|
|
if creds and is_claude_code_token_valid(creds):
|
|
logger.debug("Using Claude Code credentials (auto-detected)")
|
|
return creds["accessToken"]
|
|
if creds:
|
|
logger.debug("Claude Code credentials expired — attempting refresh")
|
|
refreshed = _refresh_oauth_token(creds)
|
|
if refreshed:
|
|
return refreshed
|
|
logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate")
|
|
return None
|
|
|
|
|
|
def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
"""Prefer refreshable Claude Code creds over a static env OAuth token.
|
|
|
|
Hermes historically persisted setup tokens into ANTHROPIC_TOKEN; that static
|
|
token wins before the refreshable credential file is ever inspected, making
|
|
refresh impossible.
|
|
"""
|
|
if not (env_token and _is_oauth_token(env_token) and isinstance(creds, dict) and creds.get("refreshToken")):
|
|
return None
|
|
|
|
resolved = _resolve_claude_code_token_from_credentials(creds)
|
|
if resolved and resolved != env_token:
|
|
logger.debug(
|
|
"Preferring Claude Code credential file over static env OAuth token so refresh can proceed"
|
|
)
|
|
return resolved
|
|
return None
|
|
|
|
|
|
def _resolve_anthropic_pool_token() -> Optional[str]:
|
|
"""First available Anthropic OAuth token from credential_pool, read-only.
|
|
|
|
Enumerates with ``clear_expired=False, refresh=False`` (never ``select()``,
|
|
which does both) so diagnostic call sites (account_usage, ``hermes models``)
|
|
never mutate auth.json or hit the network; refresh-on-expiry belongs to the
|
|
API call path's pool recovery.
|
|
"""
|
|
try:
|
|
from agent.credential_pool import AUTH_TYPE_OAUTH, load_pool
|
|
except Exception:
|
|
return None
|
|
|
|
try:
|
|
pool = load_pool("anthropic")
|
|
entries, _pending = pool._available_entries(clear_expired=False, refresh=False)
|
|
except Exception:
|
|
logger.debug("Failed to read Anthropic credential_pool", exc_info=True)
|
|
return None
|
|
|
|
for entry in entries:
|
|
# access_token may be an explicit null on a persisted entry; a bare
|
|
# None.strip() would escape the try/excepts and crash the resolver.
|
|
token = (getattr(entry, "access_token", None) or "").strip()
|
|
if getattr(entry, "auth_type", None) != AUTH_TYPE_OAUTH or not token:
|
|
continue
|
|
# load_pool() re-seeds rows from the singleton files, so a spent-but-
|
|
# uncommitted rotation (possibly from another process) looks healthy here.
|
|
entry_source_path = spent_rotation_source_path(getattr(entry, "source", None))
|
|
if any(
|
|
is_rotation_consumed_uncommitted(secret, source_path=entry_source_path)
|
|
for secret in (token, getattr(entry, "refresh_token", None))
|
|
):
|
|
logger.debug(
|
|
"Skipping Anthropic pool entry %s: rotated-but-uncommitted credential",
|
|
getattr(entry, "id", "?"),
|
|
)
|
|
continue
|
|
return token
|
|
|
|
return None
|
|
|
|
|
|
def resolve_anthropic_token() -> Optional[str]:
|
|
"""Resolve an Anthropic token from all sources in priority order (see module docstring)."""
|
|
# Read the Claude Code file at most once per resolve.
|
|
_read_creds = functools.cache(read_claude_code_credentials)
|
|
|
|
for env_var in ("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
|
token = _getenv(env_var).strip()
|
|
if token:
|
|
return _prefer_refreshable_claude_code_token(token, _read_creds()) or token
|
|
|
|
# An explicit API key must not be shadowed by auto-discovered OAuth creds.
|
|
api_key = _getenv("ANTHROPIC_API_KEY").strip()
|
|
if api_key:
|
|
return api_key
|
|
|
|
return (
|
|
_resolve_claude_code_token_from_credentials(_read_creds())
|
|
or _resolve_anthropic_pool_token()
|
|
or None
|
|
)
|
|
|
|
|
|
def run_oauth_setup_token() -> Optional[str]:
|
|
"""Run 'claude setup-token' interactively; return the resulting token or None.
|
|
|
|
Raises FileNotFoundError if the 'claude' CLI is not installed.
|
|
"""
|
|
import shutil
|
|
|
|
claude_path = shutil.which("claude")
|
|
if not claude_path:
|
|
raise FileNotFoundError(
|
|
"The 'claude' CLI is not installed. "
|
|
"Install it with: npm install -g @anthropic-ai/claude-code"
|
|
)
|
|
|
|
# Interactive: stdin/stdout/stderr inherited so the user can complete the
|
|
# OAuth prompt; the TUI-EOF stdin concern does not apply here. noqa: subprocess-stdin
|
|
try:
|
|
subprocess.run([claude_path, "setup-token"])
|
|
except (KeyboardInterrupt, EOFError):
|
|
return None
|
|
|
|
creds = read_claude_code_credentials()
|
|
if creds and is_claude_code_token_valid(creds):
|
|
return creds["accessToken"]
|
|
for env_var in ("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_TOKEN"):
|
|
val = _getenv(env_var).strip()
|
|
if val:
|
|
return val
|
|
return None
|
|
|
|
|
|
# ── Hermes-native PKCE OAuth flow (~/.hermes/.anthropic_oauth.json) ─────
|
|
# Mirrors the flow used by Claude Code, pi-ai, and OpenCode.
|
|
|
|
|
|
def _get_hermes_oauth_file() -> Path:
|
|
return get_hermes_home() / ".anthropic_oauth.json"
|
|
|
|
|
|
def _root_hermes_oauth_file() -> Optional[Path]:
|
|
"""Global-root ``.anthropic_oauth.json`` inside a named profile; None in classic mode.
|
|
|
|
Used to commit a rotation of a grant the profile borrowed via the pool's root fallback.
|
|
"""
|
|
try:
|
|
from hermes_constants import get_default_hermes_root
|
|
root = get_default_hermes_root()
|
|
if root.resolve(strict=False) == get_hermes_home().resolve(strict=False):
|
|
return None
|
|
return root / ".anthropic_oauth.json"
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _generate_pkce() -> tuple:
|
|
"""Generate PKCE code_verifier and code_challenge (S256)."""
|
|
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
|
challenge = base64.urlsafe_b64encode(
|
|
hashlib.sha256(verifier.encode()).digest()
|
|
).rstrip(b"=").decode()
|
|
return verifier, challenge
|
|
|
|
|
|
def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
|
"""Run Hermes-native OAuth PKCE flow and return credential state."""
|
|
import webbrowser
|
|
from urllib.parse import urlencode
|
|
|
|
verifier, challenge = _generate_pkce()
|
|
oauth_state = secrets.token_urlsafe(32)
|
|
|
|
params = {
|
|
"code": "true",
|
|
"client_id": _OAUTH_CLIENT_ID,
|
|
"response_type": "code",
|
|
"redirect_uri": _OAUTH_REDIRECT_URI,
|
|
"scope": _OAUTH_SCOPES,
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"state": oauth_state,
|
|
}
|
|
auth_url = f"https://claude.ai/oauth/authorize?{urlencode(params)}"
|
|
|
|
print("\n".join([
|
|
"",
|
|
"Authorize Hermes with your Claude Pro/Max subscription.",
|
|
"",
|
|
"╭─ Claude Pro/Max Authorization ────────────────────╮",
|
|
"│ │",
|
|
"│ Open this link in your browser: │",
|
|
"╰───────────────────────────────────────────────────╯",
|
|
"",
|
|
f" {auth_url}",
|
|
"",
|
|
]))
|
|
|
|
try:
|
|
from hermes_cli.auth import _can_open_graphical_browser as _can_open_gui
|
|
except Exception:
|
|
_can_open_gui = lambda: True # noqa: E731 — degrade to prior behavior
|
|
|
|
if _can_open_gui():
|
|
with contextlib.suppress(Exception):
|
|
webbrowser.open(auth_url)
|
|
print(" (Browser opened automatically)")
|
|
|
|
print("\nAfter authorizing, you'll see a code. Paste it below.\n")
|
|
try:
|
|
auth_code = input("Authorization code: ").strip()
|
|
except (KeyboardInterrupt, EOFError):
|
|
return None
|
|
|
|
if not auth_code:
|
|
print("No code entered.")
|
|
return None
|
|
|
|
splits = auth_code.split("#")
|
|
code, received_state = splits[0], (splits[1] if len(splits) > 1 else "")
|
|
|
|
# CSRF guard (RFC 6749 §10.12)
|
|
if received_state != oauth_state:
|
|
logger.warning("OAuth state mismatch — possible CSRF, aborting")
|
|
return None
|
|
|
|
try:
|
|
exchange_data = json.dumps({
|
|
"grant_type": "authorization_code",
|
|
"client_id": _OAUTH_CLIENT_ID,
|
|
"code": code,
|
|
"state": received_state,
|
|
"redirect_uri": _OAUTH_REDIRECT_URI,
|
|
"code_verifier": verifier,
|
|
}).encode()
|
|
result = _post_oauth_token(exchange_data, content_type="application/json", timeout=15, what="exchange")
|
|
except Exception as e:
|
|
print(f"Token exchange failed: {e}")
|
|
return None
|
|
|
|
if not result.get("access_token"):
|
|
print("No access token in response.")
|
|
return None
|
|
return _oauth_token_state(result)
|
|
|
|
|
|
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
|
|
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
|
|
data = _load_json_if_exists(_get_hermes_oauth_file(), "Hermes OAuth credentials")
|
|
return data if data is not None and data.get("accessToken") else None
|
|
|
|
|
|
def _write_hermes_oauth_credentials(
|
|
access_token: str,
|
|
refresh_token: Optional[str],
|
|
expires_at_ms: Optional[int],
|
|
*,
|
|
target: Optional[Path] = None,
|
|
) -> None:
|
|
"""Commit refreshed hermes_pkce tokens to ~/.hermes/.anthropic_oauth.json.
|
|
|
|
``target`` lets a named profile commit a grant it BORROWED from the global
|
|
root back to the ROOT singleton instead of forking a copy under its own
|
|
HERMES_HOME. Without this write-through, the next ``load_pool()`` re-seeds
|
|
the stale (already-consumed) pair from the file over the rotated pool entry.
|
|
Raises ``CredentialPersistError`` when the rotated pair does not land.
|
|
"""
|
|
oauth_file = target if target is not None else _get_hermes_oauth_file()
|
|
try:
|
|
_atomic_write_private_json(oauth_file, {
|
|
"accessToken": access_token,
|
|
"refreshToken": refresh_token,
|
|
"expiresAt": expires_at_ms,
|
|
})
|
|
except (OSError, ValueError) as e:
|
|
logger.error(
|
|
"Failed to write refreshed Hermes OAuth credentials to %s: %s", oauth_file, e
|
|
)
|
|
raise CredentialPersistError(oauth_file, e) from e
|