# Conflicts: # .gitignore # Dockerfile # agent/onboarding.py # apps/desktop/electron/main.ts # apps/desktop/electron/pool-stop.ts # apps/desktop/src/components/model-picker.test.tsx # apps/desktop/src/store/updates.ts # apps/desktop/vite.config.ts # datagen-config-examples/run_browser_tasks.sh # docs/rca-ssl-cacert-post-git-pull.md # gateway/run.py # hermes_cli/backup.py # hermes_cli/credential_lifecycle.py # hermes_cli/dashboard_procs.py # hermes_cli/doctor_state.py # hermes_cli/env_loader.py # hermes_cli/gateway_windows.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/psutil_android.py # hermes_cli/update_cmd.py # hermes_cli/update_cmd_windows.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_server_config.py # hermes_cli/web_server_cron.py # plugins/memory/hindsight/__init__.py # plugins/memory/holographic/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/mem0/__init__.py # plugins/platforms/google_chat/oauth.py # plugins/platforms/photon/adapter.py # scripts/ci/list_os_marked_tests.py # scripts/run_tests.sh # tests/agent/test_compression_stall_fallback.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/gateway/test_google_chat_oauth_dependencies.py # tests/hermes_cli/conftest.py # tests/hermes_cli/test_cli_init.py # tests/hermes_cli/test_gateway_migrate_multiplex.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_relaunch.py # tests/hermes_cli/test_update_check.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_worktree_gc.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_autostash_conflict_recovery.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_commit_pin_rollback.py # tests/scripts/install/test_install_diverged_update.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_macos_launcher.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_ps1_ascii_only.py # tests/scripts/install/test_install_ps1_browser_install.py # tests/scripts/install/test_install_ps1_managed_node_swap.py # tests/scripts/install/test_install_ps1_native_stderr_eap.py # tests/scripts/install/test_install_ps1_node_path_for_npm.py # tests/scripts/install/test_install_ps1_python_fallback_venv.py # tests/scripts/install/test_install_ps1_resolver_strictmode.py # tests/scripts/install/test_install_ps1_uv_install_fallback.py # tests/scripts/install/test_install_ps1_uv_powershell_host.py # tests/scripts/install/test_install_ps1_venv_process_tree.py # tests/scripts/install/test_install_ps1_venv_recreate_safety.py # tests/scripts/install/test_install_ps1_venv_rename_abort.py # tests/scripts/install/test_install_ps1_venv_transaction_boundary.py # tests/scripts/install/test_install_ps1_web_server_syntax_probe.py # tests/scripts/install/test_install_scripts_computer_use.py # tests/scripts/install/test_install_sh_acp_launcher.py # tests/scripts/install/test_install_sh_bootstrap_marker.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_install_method_stamp.py # tests/scripts/install/test_install_sh_node_deps_failure.py # tests/scripts/install/test_install_sh_node_deps_workspaces.py # tests/scripts/install/test_install_sh_node_global_prefix.py # tests/scripts/install/test_install_sh_node_npm_check.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_node_probe.py # tests/scripts/install/test_install_sh_node_tarball_without_xz.py # tests/scripts/install/test_install_sh_pythonpath_sanitization.py # tests/scripts/install/test_install_sh_reuse_supported_python.py # tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py # tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_termux_network_prereqs.py # tests/scripts/install/test_install_sh_termux_python_bounds.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_run_tests_parallel.py # tests/test_managed_runtime_resolution.py # tests/test_project_metadata.py # tests/tools/test_browser_use_cli.py # tests/tools/test_tts_pythonpath_fallback.py # tests/tui_gateway/test_hosted_room_driver_runtime.py # tests/tui_gateway/test_tui_gateway_server.py # tools/lazy_deps.py # tools/voice_mode.py # uv.lock # website/docs/developer-guide/macos-bundle-updates.md # website/docs/developer-guide/pm-audit-status.md # website/docs/developer-guide/shared-bundle-builds.md # website/docs/developer-guide/source-update-completion.md # website/docs/developer-guide/stable-releases.md
575 lines
28 KiB
Python
575 lines
28 KiB
Python
"""Anthropic credential sources, OAuth flows, and token resolution.
|
|
|
|
``resolve_anthropic_token()`` order: ``ANTHROPIC_TOKEN`` / ``CLAUDE_CODE_OAUTH_TOKEN``,
|
|
``ANTHROPIC_API_KEY``, Hermes-owned OAuth grants in the ``auth.json`` credential
|
|
pool, then ``~/.claude/.credentials.json`` / macOS Keychain as a borrowed fallback.
|
|
``~/.hermes/.anthropic_oauth.json`` (Hermes PKCE) 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.
|
|
"""
|
|
|
|
import base64
|
|
import contextlib
|
|
import functools
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import secrets
|
|
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 utils import atomic_json_write
|
|
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_URLS = [
|
|
"https://platform.claude.com/v1/oauth/token", "https://console.anthropic.com/v1/oauth/token"
|
|
]
|
|
# Anthropic 429s token-endpoint requests whose UA starts with ``claude-code/`` (or Mozilla); the real CLI uses
|
|
# bare axios there. Inference (build_anthropic_kwargs) still needs claude-code/.
|
|
_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)."""
|
|
val = _get_secret(name, default)
|
|
return val if val is not None else default
|
|
|
|
|
|
def _first_env(*names: str) -> str:
|
|
"""First non-blank (stripped) value among *names*, else ''."""
|
|
return next((v for v in (_getenv(n).strip() for n in names) if v), "")
|
|
|
|
|
|
def _is_oauth_token(key: str) -> bool:
|
|
"""True for Anthropic OAuth/setup tokens (sk-ant-*, eyJ JWTs, cc-); False for sk-ant-api* Console keys."""
|
|
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."""
|
|
|
|
def __init__(self, path: Any, cause: BaseException) -> None:
|
|
super().__init__(f"failed to durably persist rotated Anthropic credentials to {path}: {cause}")
|
|
self.path = path
|
|
|
|
|
|
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-sig"))
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.debug("Failed to read %s: %s", what, e)
|
|
return None
|
|
|
|
|
|
def _atomic_write_private_json(path: Path, payload: Any) -> None:
|
|
"""0600-from-creation temp file + fsync + atomic replace (the token is never briefly umask-readable).
|
|
The parent dir's mode is left alone (~/.claude/ is owned by Claude Code)."""
|
|
atomic_json_write(path, payload, mode=0o600)
|
|
|
|
|
|
def _commit_private_json(path: Path, payload: Any, what: str) -> None:
|
|
"""Atomic private write; any failure becomes ``CredentialPersistError`` (the commit step of a rotation)."""
|
|
try:
|
|
_atomic_write_private_json(path, payload)
|
|
except (OSError, ValueError) as e:
|
|
logger.error("Failed to write refreshed %s to %s: %s", what, path, e)
|
|
raise CredentialPersistError(path, e) from e
|
|
|
|
|
|
# ── Spent-rotation registry: fingerprints of secrets whose refresh POST succeeded but whose replacement never
|
|
# reached its store. Two scopes: process-local (OrderedDict) and a durable sidecar next to the shared singleton
|
|
# file so OTHER processes fail closed too. Non-reversible digests; never cleared.
|
|
_SPENT_ROTATION_LOCK = threading.Lock()
|
|
_SPENT_ROTATION_FINGERPRINTS: "OrderedDict[str, None]" = OrderedDict()
|
|
_SPENT_ROTATION_MAX_TRACKED = 64
|
|
_SPENT_ROTATION_SIDECAR_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."
|
|
)
|
|
|
|
|
|
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-sig")
|
|
)
|
|
except (OSError, ValueError):
|
|
return set()
|
|
fingerprints = raw.get("fingerprints") if isinstance(raw, dict) else None
|
|
return {fp for fp in fingerprints if isinstance(fp, str) and fp} if isinstance(fingerprints, list) else set()
|
|
|
|
|
|
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)
|
|
payload = json.dumps({
|
|
"version": 1,
|
|
"comment": _SPENT_ROTATION_SIDECAR_COMMENT,
|
|
"fingerprints": sorted(merged)[-_SPENT_ROTATION_MAX_TRACKED * 4 :],
|
|
}, 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 = [fp for fp in map(_fingerprint, secrets) if fp]
|
|
with _SPENT_ROTATION_LOCK:
|
|
for fingerprint in recorded:
|
|
_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")
|
|
access_token = oauth_data.get("accessToken", "") if isinstance(oauth_data, dict) else ""
|
|
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()
|
|
try:
|
|
return _claude_oauth_record(json.loads(raw), "macos_keychain") if raw else None
|
|
except json.JSONDecodeError:
|
|
logger.debug("Keychain: credentials payload is not valid JSON")
|
|
return None
|
|
|
|
|
|
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 _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, file_valid = is_claude_code_token_valid(kc_creds), is_claude_code_token_valid(file_creds)
|
|
if kc_valid != file_valid:
|
|
return kc_creds if kc_valid else file_creds
|
|
return kc_creds if (kc_creds.get("expiresAt", 0) or 0) >= (file_creds.get("expiresAt", 0) or 0) 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)
|
|
return int(time.time() * 1000) < (expires_at - 60_000) if expires_at else bool(creds.get("accessToken"))
|
|
|
|
|
|
# ── 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)
|
|
raise last_error or 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)."""
|
|
return {
|
|
"access_token": result.get("access_token", ""),
|
|
"refresh_token": result.get("refresh_token", fallback_refresh_token),
|
|
"expires_at_ms": int(time.time() * 1000) + (result.get("expires_in", 3600) * 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}
|
|
encode, content_type = ((json.dumps, "application/json") if use_json
|
|
else (urllib.parse.urlencode, "application/x-www-form-urlencoded"))
|
|
result = _post_oauth_token(encode(payload).encode(), 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 share the pool's
|
|
path-keyed cross-process lock (else two profiles can 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)
|
|
cred_path = claude_code_credentials_path()
|
|
with _auth_store_lock(timeout_seconds=lock_timeout_seconds, target_path=cred_path):
|
|
# Adopt only a DIFFERENT token with a real future expiry (0/absent expiresAt = managed key/unknown).
|
|
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.
|
|
if is_rotation_consumed_uncommitted(refresh_token, source_path=cred_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",
|
|
cred_path, e,
|
|
)
|
|
mark_rotation_consumed_uncommitted(
|
|
refresh_token, creds.get("accessToken", ""), current.get("accessToken", ""),
|
|
current.get("refreshToken", ""), source_path=cred_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 _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; ``CredentialPersistError`` on any failure (a
|
|
corrupt existing file included). *scopes* (or the previously stored scopes) are persisted because Claude Code
|
|
>=2.1.81 gates on ``"user:inference"`` being present."""
|
|
cred_path = claude_code_credentials_path()
|
|
try:
|
|
existing = json.loads(cred_path.read_text(encoding="utf-8-sig")) if cred_path.exists() else {}
|
|
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
|
|
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
|
|
_commit_private_json(cred_path, existing, "credentials")
|
|
|
|
|
|
# ── 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 not creds:
|
|
return None
|
|
if 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 is_claude_code_token_valid(creds):
|
|
logger.debug("Using Claude Code credentials (auto-detected)")
|
|
return creds["accessToken"]
|
|
logger.debug("Claude Code credentials expired — attempting refresh")
|
|
refreshed = _refresh_oauth_token(creds)
|
|
if not refreshed:
|
|
logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate")
|
|
return refreshed or 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, and that static token would otherwise win before the refreshable file is inspected."""
|
|
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(*, skip_borrowed: bool = False) -> Optional[str]:
|
|
"""First available Anthropic OAuth token from credential_pool, read-only: enumerates with ``clear_expired=False,
|
|
refresh=False`` (never ``select()``) 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
|
|
entries, _pending = load_pool("anthropic")._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:
|
|
if skip_borrowed and entry.source == "claude_code":
|
|
continue
|
|
# access_token may be an explicit null on a persisted entry; None.strip() would 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_creds = functools.cache(read_claude_code_credentials) # read the file at most once per resolve
|
|
token = _first_env("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN")
|
|
if token:
|
|
return _prefer_refreshable_claude_code_token(token, _read_creds()) or token
|
|
api_key = _first_env("ANTHROPIC_API_KEY") # an explicit API key must not be shadowed by discovered OAuth creds
|
|
if api_key:
|
|
return api_key
|
|
# The pool's claude_code row mirrors the same externally owned refresh grant.
|
|
return _resolve_anthropic_pool_token(skip_borrowed=True) or _resolve_claude_code_token_from_credentials(_read_creds())
|
|
|
|
|
|
def run_oauth_setup_token() -> Optional[str]:
|
|
"""Run 'claude setup-token' interactively; the resulting token or None. FileNotFoundError if no 'claude' CLI."""
|
|
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: stdio inherited so the user can complete the OAuth prompt. 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"]
|
|
return _first_env("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_TOKEN") or None
|
|
|
|
|
|
# ── Hermes-native PKCE OAuth flow (~/.hermes/.anthropic_oauth.json); mirrors Claude Code / pi-ai / 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()
|
|
return None if root.resolve(strict=False) == get_hermes_home().resolve(strict=False) else 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 "")
|
|
if received_state != oauth_state: # CSRF guard (RFC 6749 §10.12)
|
|
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 (``CredentialPersistError`` on failure).
|
|
``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 (consumed) pair from the file over the rotated pool entry."""
|
|
_commit_private_json(
|
|
target if target is not None else _get_hermes_oauth_file(),
|
|
{"accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms},
|
|
"Hermes OAuth credentials",
|
|
)
|