2026-05-28 16:44:02 -04:00

228 lines
8.8 KiB
Python

"""Anthropic credential pool hook.
Handles provider-specific pool operations: syncing from ~/.claude/.credentials.json,
refreshing OAuth tokens, and deciding which sources to include in the pool.
"""
from __future__ import annotations
import logging
import time
from dataclasses import replace
from typing import Any, Optional
logger = logging.getLogger(__name__)
def sync_from_credentials_file(entry: Any) -> Any:
"""Sync a claude_code pool entry from ~/.claude/.credentials.json if tokens differ.
OAuth refresh tokens are single-use. When something external (e.g.
Claude Code CLI, or another profile's pool) refreshes the token, it
writes the new pair to ~/.claude/.credentials.json. The pool entry's
refresh token becomes stale. This method detects that and syncs.
Returns the (possibly updated) entry.
"""
if entry.source != "claude_code":
return entry
try:
from agent.plugin_registries import registries
read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials")
if read_claude_code_credentials is None:
return entry
creds = read_claude_code_credentials()
if not creds:
return entry
file_refresh = creds.get("refreshToken", "")
file_access = creds.get("accessToken", "")
file_expires = creds.get("expiresAt", 0)
if file_refresh and file_refresh != entry.refresh_token:
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
return replace(
entry,
access_token=file_access,
refresh_token=file_refresh,
expires_at_ms=file_expires,
last_status=None,
last_status_at=None,
last_error_code=None,
)
except Exception as exc:
logger.debug("Failed to sync from credentials file: %s", exc)
return entry
def refresh_oauth(entry: Any, pool: Any) -> Any:
"""Refresh an anthropic OAuth token and return the updated entry.
Handles:
- Standard OAuth refresh via ``refresh_anthropic_oauth_pure``
- Writing back to ~/.claude/.credentials.json for claude_code entries
- Retry with synced token from credentials file on refresh failure
Returns the updated entry, or the original entry on failure.
"""
from agent.plugin_registries import registries
refresh_anthropic_oauth_pure = registries.get_provider_service("anthropic", "refresh_anthropic_oauth_pure")
if refresh_anthropic_oauth_pure is None:
return entry
try:
refreshed = refresh_anthropic_oauth_pure(
entry.refresh_token,
use_json=entry.source.endswith("hermes_pkce"),
)
updated = replace(
entry,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
)
# Keep ~/.claude/.credentials.json in sync
if entry.source == "claude_code":
try:
_write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials")
if _write_claude_code_credentials is not None:
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
return updated
except Exception as exc:
logger.debug("Credential refresh failed for anthropic/%s: %s", entry.id, exc)
# The refresh token may have been consumed by another process.
# Check if ~/.claude/.credentials.json has a newer token pair.
if entry.source == "claude_code":
synced = sync_from_credentials_file(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("Retrying refresh with synced token from credentials file")
try:
refreshed = refresh_anthropic_oauth_pure(
synced.refresh_token,
use_json=synced.source.endswith("hermes_pkce"),
)
updated = replace(
synced,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
last_status="OK",
last_status_at=None,
last_error_code=None,
)
try:
_write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials")
if _write_claude_code_credentials is not None:
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception:
pass
return updated
except Exception:
pass
return entry
def needs_refresh(entry: Any) -> bool:
"""Check if an anthropic OAuth entry needs a token refresh."""
if entry.expires_at_ms is None:
return False
return int(entry.expires_at_ms) <= int(time.time() * 1000) + 120_000
def should_include_in_pool(source: str) -> bool:
"""Which anthropic credential sources should be pooled."""
return source in {"claude_code", "hermes_pkce"}
def source_priority(source: str) -> int:
"""Priority ordering for anthropic credential sources (lower = preferred)."""
_PRIORITIES = {
"claude_code": 3,
"hermes_pkce": 2,
}
return _PRIORITIES.get(source, 99)
def discover_credentials(entries: list, provider: str, is_suppressed: Any) -> tuple:
"""Discover external anthropic credentials and upsert into pool entries.
Returns (changed: bool, active_sources: set).
"""
from agent.plugin_registries import registries
changed = False
active_sources = set()
# Only auto-discover external credentials (Claude Code, Hermes PKCE)
# when the user has explicitly configured anthropic as their provider.
# Without this gate, auxiliary client fallback chains silently read
# ~/.claude/.credentials.json without user consent. See PR #4210.
try:
from hermes_cli.auth import is_provider_explicitly_configured
if not is_provider_explicitly_configured("anthropic"):
return changed, active_sources
except ImportError:
pass
read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials")
read_hermes_oauth_credentials = registries.get_provider_service("anthropic", "read_hermes_oauth_credentials")
if read_claude_code_credentials is None or read_hermes_oauth_credentials is None:
return changed, active_sources
# Import pool helpers
try:
from agent.credential_pool import _upsert_entry, label_from_token, AUTH_TYPE_OAUTH
except ImportError:
return changed, active_sources
for source_name, creds in (
("hermes_pkce", read_hermes_oauth_credentials()),
("claude_code", read_claude_code_credentials()),
):
if creds and creds.get("accessToken"):
if is_suppressed(provider, source_name):
continue
active_sources.add(source_name)
changed |= _upsert_entry(
entries,
provider,
source_name,
{
"source": source_name,
"auth_type": AUTH_TYPE_OAUTH,
"access_token": creds.get("accessToken", ""),
"refresh_token": creds.get("refreshToken"),
"expires_at_ms": creds.get("expiresAt"),
"label": label_from_token(creds.get("accessToken", ""), source_name),
},
)
return changed, active_sources
# Env var scan order for anthropic — prefer OAuth tokens over API keys
ANTHROPIC_ENV_VAR_ORDER = [
"ANTHROPIC_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
]
def detect_auth_type(token: str) -> str:
"""Determine auth type for an anthropic token.
OAuth tokens don't start with 'sk-ant-api'; API keys do.
"""
from agent.credential_pool import AUTH_TYPE_OAUTH, AUTH_TYPE_API_KEY
if not token.startswith("sk-ant-api"):
return AUTH_TYPE_OAUTH
return AUTH_TYPE_API_KEY