diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 7b41c20a84..498608010d 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -27,10 +27,9 @@ import webbrowser from contextlib import contextmanager from dataclasses import dataclass, field from datetime import datetime, timezone -from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from typing import Any, Callable, Dict, FrozenSet, Iterable, List, Optional, Tuple -from urllib.parse import parse_qs, urlencode, urlparse +from urllib.parse import urlparse from hermes_cli.config import ( get_hermes_home, @@ -40,7 +39,105 @@ from hermes_cli.config import ( ) from hermes_constants import OPENROUTER_BASE_URL, secure_parent_dir from agent.credential_persistence import sanitize_borrowed_credential_payload -from utils import atomic_replace, atomic_yaml_write, env_float, is_truthy_value +from utils import atomic_replace, atomic_yaml_write, env_float, is_truthy_value # noqa: F401 (env_float: agent.credential_pool reads auth_mod.env_float) +from hermes_cli.auth_minimax import ( # noqa: F401 (re-exported; callers/tests use hermes_cli.auth.) + _MINIMAX_OAUTH_ERROR_BODY_LIMIT, + _login_minimax_oauth, + _minimax_expired_in_looks_like_unix_ms, + _minimax_expiry_fields, + _minimax_fresh_state, + _minimax_oauth_login, + _minimax_oauth_quarantine_on_terminal_refresh, + _minimax_pkce_pair, + _minimax_poll_token, + _minimax_post_form, + _minimax_request_user_code, + _minimax_resolve_token_expiry_unix, + _minimax_response_error_text, + _minimax_save_auth_state, + _refresh_minimax_oauth_state, + build_minimax_oauth_token_provider, + resolve_minimax_oauth_runtime_credentials, +) +from hermes_cli.auth_xai import ( # noqa: F401 (re-exported; callers/tests use hermes_cli.auth.) + _is_xai_origin_host, + _login_xai_oauth, + _quarantine_xai_oauth_tokens, + _read_xai_oauth_tokens, + _refresh_xai_oauth_tokens, + _save_xai_oauth_tokens, + _write_through_xai_oauth_to_global_root, + _xai_access_token_is_expiring, + _xai_oauth_device_code_login, + _xai_oauth_discovery, + _xai_oauth_inference_base_url, + _xai_oauth_poll_device_token, + _xai_oauth_request_device_code, + _xai_oauth_state_from_store, + _xai_oauth_state_has_usable_tokens, + _xai_proactive_refresh_skew_seconds, + _xai_tokens_from_payload, + _xai_validate_inference_base_url, + _xai_validate_oauth_endpoint, + refresh_xai_oauth_pure, + resolve_xai_oauth_runtime_credentials, +) +from hermes_cli.auth_codex import ( # noqa: F401 (re-exported; callers/tests use hermes_cli.auth.) + CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS, + _codex_base_url, + _codex_device_code_login, + _codex_exchange_authorization_code, + _codex_http_client, + _codex_login_rate_limited_error, + _codex_poll_authorization_code, + _codex_pool_rate_limit_status, + _codex_quota_exhausted_error, + _codex_quota_probe_cache, + _codex_quota_probe_lock, + _codex_refresh_failure_error, + _codex_request_device_code, + _codex_runtime_result, + _codex_usage_probe_url, + _import_codex_cli_tokens, + _is_codex_rate_limit_shaped, + _load_auth_store_maybe_locked, + _login_openai_codex, + _pool_codex_access_token, + _pool_entries, + _probe_codex_quota_restored, + _read_codex_tokens, + _recover_codex_tokens_from_cli, + _refresh_codex_auth_tokens, + _refresh_payload_access_token, + _save_codex_tokens, + _sync_codex_pool_entries, + clear_codex_pool_quota_cooldowns, + refresh_codex_oauth_pure, + resolve_codex_runtime_credentials, +) +from hermes_cli.auth_spotify import ( # noqa: F401 (re-exported; callers/tests use hermes_cli.auth.) + _make_spotify_callback_handler, + _refresh_spotify_oauth_state, + _spotify_accounts_base_url, + _spotify_api_base_url, + _spotify_build_authorize_url, + _spotify_client_id, + _spotify_code_challenge, + _spotify_code_verifier, + _spotify_exchange_code_for_tokens, + _spotify_interactive_setup, + _spotify_redirect_uri, + _spotify_scope_list, + _spotify_scope_string, + _spotify_setting, + _spotify_token_payload_to_state, + _spotify_token_post, + _spotify_validate_redirect_uri, + _spotify_wait_for_callback, + get_spotify_auth_status, + login_spotify_command, + resolve_spotify_runtime_credentials, +) from hermes_cli.auth_qwen import ( # noqa: F401 (re-exported; callers/tests use hermes_cli.auth.) _mark_qwen_oauth_active, _qwen_access_token_is_expiring, @@ -3134,582 +3231,6 @@ def _codex_access_token_is_expiring(access_token: Any, skew_seconds: int) -> boo # ============================================================================= -def _spotify_scope_list(raw_scope: Optional[str] = None) -> List[str]: - scope_text = (raw_scope or DEFAULT_SPOTIFY_SCOPE).strip() - scopes = [part for part in scope_text.split() if part] - seen: set[str] = set() - ordered: List[str] = [] - for scope in scopes: - if scope not in seen: - seen.add(scope) - ordered.append(scope) - return ordered - - -def _spotify_scope_string(raw_scope: Optional[str] = None) -> str: - return " ".join(_spotify_scope_list(raw_scope)) - - -def _spotify_setting( - state: Optional[Dict[str, Any]], - state_key: str, - env_vars: Tuple[str, ...], - default: str, - *, - explicit: Optional[str] = None, - strip_slash: bool = False, -) -> str: - """First non-empty of explicit arg, env vars (``.env`` aware), stored state, then *default*.""" - from hermes_cli.config import get_env_value - - candidates = ( - explicit, - *(get_env_value(var) for var in env_vars), - state.get(state_key) if isinstance(state, dict) else None, - default, - ) - for candidate in candidates: - cleaned = str(candidate or "").strip() - if strip_slash: - cleaned = cleaned.rstrip("/") - if cleaned: - return cleaned - return default - - -def _spotify_client_id( - explicit: Optional[str] = None, - state: Optional[Dict[str, Any]] = None, -) -> str: - client_id = _spotify_setting( - state, "client_id", ("HERMES_SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_ID"), "", explicit=explicit, - ) - if client_id: - return client_id - raise _spotify_err( - "Spotify client_id is required. Set HERMES_SPOTIFY_CLIENT_ID or pass --client-id.", - "spotify_client_id_missing", - ) - - -def _spotify_redirect_uri( - explicit: Optional[str] = None, - state: Optional[Dict[str, Any]] = None, -) -> str: - return _spotify_setting( - state, "redirect_uri", ("HERMES_SPOTIFY_REDIRECT_URI", "SPOTIFY_REDIRECT_URI"), - DEFAULT_SPOTIFY_REDIRECT_URI, explicit=explicit, - ) - - -def _spotify_api_base_url(state: Optional[Dict[str, Any]] = None) -> str: - return _spotify_setting( - state, "api_base_url", ("HERMES_SPOTIFY_API_BASE_URL",), - DEFAULT_SPOTIFY_API_BASE_URL, strip_slash=True, - ) - - -def _spotify_accounts_base_url(state: Optional[Dict[str, Any]] = None) -> str: - return _spotify_setting( - state, "accounts_base_url", ("HERMES_SPOTIFY_ACCOUNTS_BASE_URL",), - DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, strip_slash=True, - ) - - -def _spotify_code_verifier(length: int = 64) -> str: - raw = base64.urlsafe_b64encode(os.urandom(length)).decode("ascii") - return raw.rstrip("=")[:128] - - -def _spotify_code_challenge(code_verifier: str) -> str: - digest = hashlib.sha256(code_verifier.encode("utf-8")).digest() - return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") - - -def _spotify_build_authorize_url( - *, - client_id: str, - redirect_uri: str, - scope: str, - state: str, - code_challenge: str, - accounts_base_url: str, -) -> str: - query = urlencode({ - "client_id": client_id, - "response_type": "code", - "redirect_uri": redirect_uri, - "scope": scope, - "state": state, - "code_challenge_method": "S256", - "code_challenge": code_challenge, - }) - return f"{accounts_base_url}/authorize?{query}" - - -def _spotify_validate_redirect_uri(redirect_uri: str) -> tuple[str, int, str]: - parsed = urlparse(redirect_uri) - if parsed.scheme != "http": - raise _spotify_err( - "Spotify PKCE redirect_uri must use http://localhost or http://127.0.0.1.", - "spotify_redirect_invalid", - ) - host = parsed.hostname or "" - if host not in {"127.0.0.1", "localhost"}: - raise _spotify_err( - "Spotify PKCE redirect_uri must point to localhost or 127.0.0.1.", - "spotify_redirect_invalid", - ) - if not parsed.port: - raise _spotify_err( - "Spotify PKCE redirect_uri must include an explicit localhost port.", - "spotify_redirect_invalid", - ) - return host, parsed.port, parsed.path or "/" - - -def _make_spotify_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequestHandler], dict[str, Any]]: - result: dict[str, Any] = { - "code": None, - "state": None, - "error": None, - "error_description": None, - } - - class _SpotifyCallbackHandler(BaseHTTPRequestHandler): - def do_GET(self) -> None: # noqa: N802 - parsed = urlparse(self.path) - if parsed.path != expected_path: - self.send_response(404) - self.end_headers() - self.wfile.write(b"Not found.") - return - - params = parse_qs(parsed.query) - result["code"] = params.get("code", [None])[0] - result["state"] = params.get("state", [None])[0] - result["error"] = params.get("error", [None])[0] - result["error_description"] = params.get("error_description", [None])[0] - - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.end_headers() - if result["error"]: - body = "

Spotify authorization failed.

You can close this tab." - else: - body = "

Spotify authorization received.

You can close this tab." - self.wfile.write(body.encode("utf-8")) - - def log_message(self, format: str, *args: Any) -> None: # noqa: A003 - return - - return _SpotifyCallbackHandler, result - - -def _spotify_wait_for_callback( - redirect_uri: str, - *, - timeout_seconds: float = 180.0, -) -> dict[str, Any]: - host, port, path = _spotify_validate_redirect_uri(redirect_uri) - handler_cls, result = _make_spotify_callback_handler(path) - - class _ReuseHTTPServer(HTTPServer): - allow_reuse_address = True - - try: - server = _ReuseHTTPServer((host, port), handler_cls) - except OSError as exc: - raise _spotify_err( - f"Could not bind Spotify callback server on {host}:{port}: {exc}", - "spotify_callback_bind_failed", - ) from exc - - thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True) - thread.start() - deadline = time.monotonic() + max(5.0, timeout_seconds) - try: - while time.monotonic() < deadline: - if result["code"] or result["error"]: - return result - time.sleep(0.1) - finally: - server.shutdown() - server.server_close() - thread.join(timeout=1.0) - raise _spotify_err( - "Spotify authorization timed out waiting for the local callback.", - "spotify_callback_timeout", - ) - - -def _spotify_token_payload_to_state( - token_payload: Dict[str, Any], - *, - client_id: str, - redirect_uri: str, - requested_scope: str, - accounts_base_url: str, - api_base_url: str, - previous_state: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - now = datetime.now(timezone.utc) - expires_in = _coerce_ttl_seconds(token_payload.get("expires_in", 0)) - expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc) - state = dict(previous_state or {}) - state.update({ - "client_id": client_id, - "redirect_uri": redirect_uri, - "accounts_base_url": accounts_base_url, - "api_base_url": api_base_url, - "scope": requested_scope, - "granted_scope": str(token_payload.get("scope") or requested_scope).strip(), - "token_type": str(token_payload.get("token_type", "Bearer") or "Bearer").strip() or "Bearer", - "access_token": str(token_payload.get("access_token", "") or "").strip(), - "refresh_token": str( - token_payload.get("refresh_token") - or state.get("refresh_token") - or "" - ).strip(), - "obtained_at": now.isoformat(), - "expires_at": expires_at.isoformat(), - "expires_in": expires_in, - "auth_type": "oauth_pkce", - }) - return state - - -def _spotify_token_post( - accounts_base_url: str, - data: Dict[str, str], - *, - timeout_seconds: float, - what: str, - failed_code: str, - invalid_code: str, - invalid_message: str, - failed_suffix: str = "", - relogin_required: bool = False, -) -> Dict[str, Any]: - """POST to Spotify's ``/api/token`` and return the JSON payload, or raise a shaped AuthError.""" - try: - response = httpx.post( - f"{accounts_base_url}/api/token", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data=data, - timeout=timeout_seconds, - ) - except Exception as exc: - raise _spotify_err(f"Spotify {what} failed: {exc}", failed_code) from exc - - if response.status_code >= 400: - detail = response.text.strip() - raise _spotify_err( - f"Spotify {what} failed.{failed_suffix}" - + (f" Response: {detail}" if detail else ""), - failed_code, relogin=relogin_required, - ) - payload = response.json() - if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip(): - raise _spotify_err(invalid_message, invalid_code, relogin=relogin_required) - return payload - - -def _spotify_exchange_code_for_tokens( - *, - client_id: str, - code: str, - redirect_uri: str, - code_verifier: str, - accounts_base_url: str, - timeout_seconds: float = 20.0, -) -> Dict[str, Any]: - return _spotify_token_post( - accounts_base_url, - { - "client_id": client_id, - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri, - "code_verifier": code_verifier, - }, - timeout_seconds=timeout_seconds, - what="token exchange", - failed_code="spotify_token_exchange_failed", - invalid_code="spotify_token_exchange_invalid", - invalid_message="Spotify token response did not include an access_token.", - ) - - -def _refresh_spotify_oauth_state( - state: Dict[str, Any], - *, - timeout_seconds: float = 20.0, -) -> Dict[str, Any]: - refresh_token = str(state.get("refresh_token", "") or "").strip() - if not refresh_token: - raise _spotify_err( - "Spotify refresh token missing. Run `hermes auth spotify` again.", - "spotify_refresh_token_missing", relogin=True, - ) - - client_id = _spotify_client_id(state=state) - accounts_base_url = _spotify_accounts_base_url(state) - payload = _spotify_token_post( - accounts_base_url, - { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": client_id, - }, - timeout_seconds=timeout_seconds, - what="token refresh", - failed_code="spotify_refresh_failed", - invalid_code="spotify_refresh_invalid", - invalid_message="Spotify refresh response did not include an access_token.", - failed_suffix=" Run `hermes auth spotify` again.", - relogin_required=True, - ) - - return _spotify_token_payload_to_state( - payload, - client_id=client_id, - redirect_uri=_spotify_redirect_uri(state=state), - requested_scope=str(state.get("scope") or DEFAULT_SPOTIFY_SCOPE), - accounts_base_url=accounts_base_url, - api_base_url=_spotify_api_base_url(state), - previous_state=state, - ) - - -def resolve_spotify_runtime_credentials( - *, - force_refresh: bool = False, - refresh_if_expiring: bool = True, - refresh_skew_seconds: int = SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, -) -> Dict[str, Any]: - with _auth_store_lock(): - auth_store = _load_auth_store() - state = _load_provider_state(auth_store, "spotify") - if not state: - raise _spotify_err( - "Spotify is not authenticated. Run `hermes auth spotify` first.", - "spotify_auth_missing", relogin=True, - ) - - should_refresh = bool(force_refresh) - if not should_refresh and refresh_if_expiring: - should_refresh = _is_expiring(state.get("expires_at"), refresh_skew_seconds) - if should_refresh: - try: - state = _refresh_spotify_oauth_state(state) - _store_provider_state(auth_store, "spotify", state, set_active=False) - _save_auth_store(auth_store) - except AuthError as exc: - if exc.relogin_required and state.get("refresh_token"): - _quarantine_flat_oauth_state(state, "spotify", exc) - try: - _store_provider_state(auth_store, "spotify", state, set_active=False) - _save_auth_store(auth_store) - except Exception as _save_exc: - logger.debug("Spotify OAuth: failed to persist quarantined state: %s", _save_exc) - raise - - access_token = str(state.get("access_token", "") or "").strip() - if not access_token: - raise _spotify_err( - "Spotify access token missing. Run `hermes auth spotify` again.", - "spotify_access_token_missing", relogin=True, - ) - - return { - "provider": "spotify", - "access_token": access_token, - "api_key": access_token, - "token_type": str(state.get("token_type", "Bearer") or "Bearer"), - "base_url": _spotify_api_base_url(state), - "scope": str(state.get("granted_scope") or state.get("scope") or "").strip(), - "client_id": _spotify_client_id(state=state), - "redirect_uri": _spotify_redirect_uri(state=state), - "expires_at": state.get("expires_at"), - "refresh_token": str(state.get("refresh_token", "") or "").strip(), - } - - -def get_spotify_auth_status() -> Dict[str, Any]: - state = get_provider_auth_state("spotify") - if not state: - return {"logged_in": False} - - expires_at = state.get("expires_at") - refresh_token = str(state.get("refresh_token", "") or "").strip() - return { - "logged_in": bool(refresh_token or not _is_expiring(expires_at, 0)), - "auth_type": state.get("auth_type", "oauth_pkce"), - "client_id": state.get("client_id"), - "redirect_uri": state.get("redirect_uri"), - "scope": state.get("granted_scope") or state.get("scope"), - "expires_at": expires_at, - "api_base_url": state.get("api_base_url"), - "has_refresh_token": bool(refresh_token), - } - - -def _spotify_interactive_setup(redirect_uri_hint: str) -> str: - """Walk the user through creating a Spotify developer app, persist the resulting client_id to - ~/.hermes/.env, and return it. - """ - from hermes_cli.config import save_env_value - - print() - print("=" * 70) - print("Spotify first-time setup") - print("=" * 70) - print() - print("Spotify requires every user to register their own lightweight") - print("developer app. This takes about two minutes and only has to be") - print("done once per machine.") - print() - print(f"Full guide: {SPOTIFY_DOCS_URL}") - print() - print("Steps:") - print(f" 1. Opening {SPOTIFY_DASHBOARD_URL} in your browser...") - print(" 2. Click 'Create app' and fill in:") - print(" App name: anything (e.g. hermes-agent)") - print(" Description: anything") - print(f" Redirect URI: {redirect_uri_hint}") - print(" API/SDK: Web API") - print(" 3. Agree to the terms, click Save.") - print(" 4. Open the app's Settings page and copy the Client ID.") - print(" 5. Paste it below.") - print() - - if not _is_remote_session(): - try: - webbrowser.open(SPOTIFY_DASHBOARD_URL) - except Exception: - pass - - from hermes_cli.cli_output import line_input - - try: - raw = line_input("Spotify Client ID: ").strip() - except (EOFError, KeyboardInterrupt): - print() - raise SystemExit("Spotify setup cancelled.") - - if not raw: - print() - print(f"No Client ID entered. See {SPOTIFY_DOCS_URL} for the full guide.") - raise SystemExit("Spotify setup cancelled: empty Client ID.") - - # Persist so subsequent `hermes auth spotify` runs skip the wizard. - save_env_value("HERMES_SPOTIFY_CLIENT_ID", raw) - # Only persist the redirect URI if it's non-default, to avoid pinning - # users to a value the default might later change to. - if redirect_uri_hint and redirect_uri_hint != DEFAULT_SPOTIFY_REDIRECT_URI: - save_env_value("HERMES_SPOTIFY_REDIRECT_URI", redirect_uri_hint) - - print() - print("Saved HERMES_SPOTIFY_CLIENT_ID to ~/.hermes/.env") - print() - return raw - - -def login_spotify_command(args) -> None: - existing_state = get_provider_auth_state("spotify") or {} - - # Interactive wizard: if no client_id is configured anywhere, walk the - # user through creating the Spotify developer app instead of crashing - # with "HERMES_SPOTIFY_CLIENT_ID is required". - explicit_client_id = getattr(args, "client_id", None) - try: - client_id = _spotify_client_id(explicit_client_id, existing_state) - except AuthError as exc: - if getattr(exc, "code", "") != "spotify_client_id_missing": - raise - client_id = _spotify_interactive_setup( - redirect_uri_hint=getattr(args, "redirect_uri", None) or DEFAULT_SPOTIFY_REDIRECT_URI, - ) - - redirect_uri = _spotify_redirect_uri(getattr(args, "redirect_uri", None), existing_state) - scope = _spotify_scope_string(getattr(args, "scope", None) or existing_state.get("scope")) - accounts_base_url = _spotify_accounts_base_url(existing_state) - api_base_url = _spotify_api_base_url(existing_state) - open_browser = not getattr(args, "no_browser", False) - - code_verifier = _spotify_code_verifier() - code_challenge = _spotify_code_challenge(code_verifier) - state_nonce = uuid.uuid4().hex - authorize_url = _spotify_build_authorize_url( - client_id=client_id, - redirect_uri=redirect_uri, - scope=scope, - state=state_nonce, - code_challenge=code_challenge, - accounts_base_url=accounts_base_url, - ) - - print("Starting Spotify PKCE login...") - print(f"Client ID: {client_id}") - print(f"Redirect URI: {redirect_uri}") - print("Make sure this redirect URI is allow-listed in your Spotify app settings.") - print() - print("Open this URL to authorize Hermes:") - print(authorize_url) - print() - print(f"Full setup guide: {SPOTIFY_DOCS_URL}") - print() - - _print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL) - - if open_browser and not _is_remote_session() and _can_open_graphical_browser(): - try: - opened = webbrowser.open(authorize_url) - except Exception: - opened = False - if opened: - print("Browser opened for Spotify authorization.") - else: - print("Could not open the browser automatically; use the URL above.") - - callback = _spotify_wait_for_callback( - redirect_uri, - timeout_seconds=float(getattr(args, "timeout", None) or 180.0), - ) - if callback.get("error"): - detail = callback.get("error_description") or callback["error"] - raise SystemExit(f"Spotify authorization failed: {detail}") - if callback.get("state") != state_nonce: - raise SystemExit("Spotify authorization failed: state mismatch.") - - token_payload = _spotify_exchange_code_for_tokens( - client_id=client_id, - code=str(callback.get("code") or ""), - redirect_uri=redirect_uri, - code_verifier=code_verifier, - accounts_base_url=accounts_base_url, - timeout_seconds=float(getattr(args, "timeout", None) or 20.0), - ) - spotify_state = _spotify_token_payload_to_state( - token_payload, - client_id=client_id, - redirect_uri=redirect_uri, - requested_scope=scope, - accounts_base_url=accounts_base_url, - api_base_url=api_base_url, - ) - - with _auth_store_lock(): - auth_store = _load_auth_store() - _store_provider_state(auth_store, "spotify", spotify_state, set_active=False) - saved_to = _save_auth_store(auth_store) - - print("Spotify login successful!") - print(f" Auth state: {saved_to}") - print(" Provider state saved under providers.spotify") - print(f" Docs: {SPOTIFY_DOCS_URL}") - # ============================================================================= # SSH / remote session detection # ============================================================================= @@ -3863,1375 +3384,11 @@ def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) # where one app's refresh invalidates the other's session. # ============================================================================= -def _codex_base_url() -> str: - return os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL - - -def _codex_runtime_result(api_key: str, *, source: str, last_refresh: Optional[str]) -> Dict[str, Any]: - return { - "provider": "openai-codex", - "base_url": _codex_base_url(), - "api_key": api_key, - "source": source, - "last_refresh": last_refresh, - "auth_mode": "chatgpt", - } - - -def _load_auth_store_maybe_locked(lock: bool) -> Dict[str, Any]: - """Load the auth store, taking the cross-process lock unless the caller already holds it.""" - if lock: - with _auth_store_lock(): - return _load_auth_store() - return _load_auth_store() - - -def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: - """Read Codex OAuth tokens from Hermes auth store (~/.hermes/auth.json).""" - auth_store = _load_auth_store_maybe_locked(_lock) - state = _load_provider_state(auth_store, "openai-codex") - if not state: - raise _codex_err( - "No Codex credentials stored. Run `hermes auth` to authenticate.", - "codex_auth_missing", relogin=True, - ) - tokens = state.get("tokens") - if not isinstance(tokens, dict): - raise _codex_err( - "Codex auth state is missing tokens. Run `hermes auth` to re-authenticate.", - "codex_auth_invalid_shape", relogin=True, - ) - access_token = tokens.get("access_token") - refresh_token = tokens.get("refresh_token") - if not _nonempty_str(access_token): - raise _codex_err( - "Codex auth is missing access_token. Run `hermes auth` to re-authenticate.", - "codex_auth_missing_access_token", relogin=True, - ) - if not _nonempty_str(refresh_token): - raise _codex_err( - "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.", - "codex_auth_missing_refresh_token", relogin=True, - ) - return { - "tokens": tokens, - "last_refresh": state.get("last_refresh"), - } - - -def _sync_codex_pool_entries( - auth_store: Dict[str, Any], - tokens: Dict[str, str], - last_refresh: Optional[str], - previous_singleton_tokens: Optional[Dict[str, str]] = None, -) -> None: - """Mirror a fresh Codex re-auth into the credential_pool OAuth entries. - - * ``device_code`` — the singleton-seeded entry written by the device-code OAuth flow when the - user logged in via ``hermes setup`` / the model picker. Always synced with the fresh tokens. * - ``manual:device_code`` — entries created by ``hermes auth add openai-codex`` that use the same - device-code OAuth mechanism. - - * ``manual:api_key`` and any other non-device-code manual sources — those are independent - credentials (an explicit API key, a different ChatGPT account, etc.) and must not be overwritten - by a single re-auth. - """ - access_token = tokens.get("access_token") - if not access_token: - return - refresh_token = tokens.get("refresh_token") - entries = _pool_entries(auth_store, "openai-codex") - if entries is None: - return - # Previous singleton access_token (before this re-auth overwrote it) — - # used to distinguish legacy singleton-aliases from independent accounts. - # When None or empty, no manual entry can be treated as an alias (which - # is the right default for first-ever-save or a freshly initialized - # auth.json). - prev_at = None - if isinstance(previous_singleton_tokens, dict): - prev_at = previous_singleton_tokens.get("access_token") or None - for entry in entries: - if not isinstance(entry, dict): - continue - source = entry.get("source") - if source == "device_code": - # Singleton-seeded mirror — always refresh. - refresh_this_entry = True - elif source == "manual:device_code": - # Refresh only if this entry's existing access_token matches the - # previous singleton access_token (i.e. it is a true alias of the - # singleton from the #33000 workaround era). An entry with its - # own distinct token material is an independent account and must - # be left alone (#39236). - refresh_this_entry = bool( - prev_at and entry.get("access_token") == prev_at - ) - else: - # ``manual:api_key`` and any future non-device-code sources. - refresh_this_entry = False - if not refresh_this_entry: - continue - entry["access_token"] = access_token - if refresh_token: - entry["refresh_token"] = refresh_token - if last_refresh: - entry["last_refresh"] = last_refresh - _clear_pool_entry_status(entry) - - -def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None: - """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).""" - if last_refresh is None: - last_refresh = _utc_now_z() - with _auth_store_lock(): - auth_store = _load_auth_store() - state = _load_provider_state(auth_store, "openai-codex") or {} - # Capture the previous singleton tokens BEFORE overwriting them. The - # pool-sync step uses this to distinguish legacy singleton-aliases - # (which should be refreshed) from independent accounts that - # ``hermes auth add openai-codex`` created (which must not be - # overwritten — see #39236). - previous_singleton_tokens = state.get("tokens") if isinstance(state.get("tokens"), dict) else None - state["tokens"] = tokens - state["last_refresh"] = last_refresh - state["auth_mode"] = "chatgpt" - if label and str(label).strip(): - state["label"] = str(label).strip() - _save_provider_state(auth_store, "openai-codex", state) - _sync_codex_pool_entries( - auth_store, - tokens, - last_refresh, - previous_singleton_tokens=previous_singleton_tokens, - ) - _save_auth_store(auth_store) - - -def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]: - """Adopt a valid Codex CLI token pair into Hermes auth, if available.""" - imported = _import_codex_cli_tokens() - # Require BOTH tokens before adopting: persisting a payload without a - # usable refresh_token would only break the next refresh cycle. - if not ( - imported - and str(imported.get("access_token", "") or "").strip() - and str(imported.get("refresh_token", "") or "").strip() - ): - return None - logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason) - _save_codex_tokens(imported) - return dict(imported) - - -def _refresh_payload_access_token( - response: "httpx.Response", - *, - provider: str, - invalid_json: Tuple[str, str], - invalid_response: Optional[Tuple[str, str]], - missing_access: Tuple[str, str], - relogin_required: bool = True, - invalid_json_relogin: Optional[bool] = None, - strict_str: bool = True, -) -> Tuple[Dict[str, Any], str]: - """Parse a 200 token-refresh response; return ``(payload, stripped access_token)``. - - Each ``(message, code)`` pair keeps the provider's historical wording; ``{exc}`` in - *invalid_json*'s message is formatted with the JSON error. *strict_str* rejects non-string - access tokens; otherwise they are ``str()``-coerced. - """ - try: - payload = response.json() - except Exception as exc: - raise AuthError( - invalid_json[0].format(exc=exc), - provider=provider, - code=invalid_json[1], - relogin_required=( - relogin_required if invalid_json_relogin is None else invalid_json_relogin - ), - ) from exc - if not isinstance(payload, dict): - if invalid_response is None: - payload = {} - else: - raise AuthError( - invalid_response[0], - provider=provider, - code=invalid_response[1], - relogin_required=relogin_required, - ) - access = payload.get("access_token") - if strict_str: - access = access.strip() if isinstance(access, str) else "" - else: - access = str(access or "").strip() - if not access: - raise AuthError( - missing_access[0], - provider=provider, - code=missing_access[1], - relogin_required=relogin_required, - ) - return payload, access - - -def _codex_http_client(**kwargs: Any) -> "httpx.Client": - """Build an ``httpx.Client`` for Codex OAuth/probe endpoints with racing. - - Same broken-IPv6 failure mode as the chat transport (#13834): a host that advertises AAAA - records but blackholes IPv6 makes each serial connect attempt eat the full connect timeout - before IPv4 is tried, so token refresh / device login / usage probes time out where the official - Codex CLI (which races families per RFC 8305) works. - - Best-effort: if the racing backend can't be installed (unexpected httpx/httpcore internals, - mocked client in tests), the client still works with the default serial connect behavior. - """ - client = httpx.Client(**kwargs) - try: - from agent.process_bootstrap import enable_happy_eyeballs_on_client - - enable_happy_eyeballs_on_client(client) - except Exception: - pass - return client - - -def _codex_quota_exhausted_error(retry_after: Optional[int]) -> AuthError: - if retry_after is not None: - message = ( - f"Codex provider quota exhausted (429); retry after {retry_after}s. " - "Credentials are still valid." - ) - else: - message = ( - "Codex provider quota exhausted (429). Credentials are still valid; " - "retry after the usage limit resets." - ) - return _codex_err(message, CODEX_RATE_LIMITED_CODE, relogin=False) - - -def _codex_refresh_failure_error(response: "httpx.Response") -> AuthError: - """Decode a non-200 Codex token-refresh response into a shaped AuthError.""" - code = "codex_refresh_failed" - message = f"Codex token refresh failed with status {response.status_code}." - relogin_required = False - try: - err = response.json() - if isinstance(err, dict): - err_obj = err.get("error") - # OpenAI shape: {"error": {"code": "...", "message": "...", "type": "..."}} - if isinstance(err_obj, dict): - nested_code = err_obj.get("code") or err_obj.get("type") - if _nonempty_str(nested_code): - code = nested_code.strip() - nested_msg = err_obj.get("message") - if _nonempty_str(nested_msg): - message = f"Codex token refresh failed: {nested_msg.strip()}" - # OAuth spec shape: {"error": "code_str", "error_description": "..."} - elif _nonempty_str(err_obj): - code = err_obj.strip() - err_desc = err.get("error_description") or err.get("message") - if _nonempty_str(err_desc): - message = f"Codex token refresh failed: {err_desc.strip()}" - except Exception: - pass - if code in {"invalid_grant", "invalid_token", "invalid_request"}: - relogin_required = True - if code == "refresh_token_reused": - message = ( - "Codex refresh token was already consumed by another client " - "(e.g. Codex CLI or VS Code extension). " - "Run `codex` in your terminal to generate fresh tokens, " - "then run `hermes auth` to re-authenticate." - ) - relogin_required = True - # A 401/403 from the token endpoint always means the refresh token - # is invalid/expired — force relogin even if the body error code - # wasn't one of the known strings above. - if response.status_code in {401, 403} and not relogin_required: - relogin_required = True - return _codex_err(message, code, relogin=relogin_required) - - -def refresh_codex_oauth_pure( - access_token: str, - refresh_token: str, - *, - timeout_seconds: float = 20.0, -) -> Dict[str, Any]: - """Refresh Codex OAuth tokens without mutating Hermes auth state.""" - del access_token # Access token is only used by callers to decide whether to refresh. - if not _nonempty_str(refresh_token): - raise _codex_err( - "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.", - "codex_auth_missing_refresh_token", relogin=True, - ) - - timeout = httpx.Timeout(max(5.0, float(timeout_seconds))) - with _codex_http_client( - timeout=timeout, - headers={ - "Accept": "application/json", - "User-Agent": CODEX_OAUTH_USER_AGENT, - }, - ) as client: - response = client.post( - CODEX_OAUTH_TOKEN_URL, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CODEX_OAUTH_CLIENT_ID, - }, - ) - - if response.status_code == 429: - # Upstream rate-limit / usage-quota exhaustion on the token endpoint. - # The stored refresh token is still valid here — re-authenticating - # cannot lift a quota cap. Classify distinctly from auth failures so - # callers surface a "retry later" notice instead of a misleading - # "run hermes auth" prompt (see issue #32790). - raise _codex_quota_exhausted_error( - _parse_retry_after_seconds(getattr(response, "headers", None)) - ) - - if response.status_code != 200: - raise _codex_refresh_failure_error(response) - - refresh_payload, refreshed_access = _refresh_payload_access_token( - response, - provider="openai-codex", - invalid_json=("Codex token refresh returned invalid JSON.", "codex_refresh_invalid_json"), - invalid_response=None, - missing_access=( - "Codex token refresh response was missing access_token.", - "codex_refresh_missing_access_token", - ), - ) - - updated = { - "access_token": refreshed_access, - "refresh_token": refresh_token.strip(), - "last_refresh": _utc_now_z(), - } - next_refresh = refresh_payload.get("refresh_token") - if _nonempty_str(next_refresh): - updated["refresh_token"] = next_refresh.strip() - return updated - - -def _refresh_codex_auth_tokens( - tokens: Dict[str, str], - timeout_seconds: float, -) -> Dict[str, str]: - """Refresh Codex access token using the refresh token.""" - try: - refreshed = refresh_codex_oauth_pure( - str(tokens.get("access_token", "") or ""), - str(tokens.get("refresh_token", "") or ""), - timeout_seconds=timeout_seconds, - ) - except AuthError as exc: - # Self-heal cross-store refresh_token rotation. Hermes keeps its OWN - # Codex OAuth token (per profile + top-level), separate from the Codex - # CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when - # the Codex CLI (or another Hermes process) rotates the shared token, - # this frozen copy's refresh_token goes stale and the refresh fails with - # a relogin-required error (invalid_grant / refresh_token_reused / 401). - # Before surfacing that as a hard 401 to the turn, adopt the canonical - # fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so - # idle profiles / desktop sessions recover automatically instead of - # 401'ing until a manual re-auth. Transient failures (e.g. 429 quota) - # keep relogin_required=False — the stored token is still valid there, so - # we never self-heal those and re-raise unchanged. - if not getattr(exc, "relogin_required", False): - raise - imported = _recover_codex_tokens_from_cli( - f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}" - ) - if not imported: - raise - return imported - - updated_tokens = dict(tokens) - updated_tokens["access_token"] = refreshed["access_token"] - updated_tokens["refresh_token"] = refreshed["refresh_token"] - - _save_codex_tokens(updated_tokens) - return updated_tokens - - -def _import_codex_cli_tokens() -> Optional[Dict[str, str]]: - """Try to read tokens from ~/.codex/auth.json (Codex CLI shared file). - - Returns tokens dict if valid and not expired, None otherwise. Does NOT write to the shared file. - """ - codex_home = os.getenv("CODEX_HOME", "").strip() - if not codex_home: - codex_home = str(Path.home() / ".codex") - auth_path = Path(codex_home).expanduser() / "auth.json" - if not auth_path.is_file(): - return None - try: - payload = json.loads(auth_path.read_text(encoding="utf-8-sig")) - tokens = payload.get("tokens") - if not isinstance(tokens, dict): - return None - access_token = tokens.get("access_token") - refresh_token = tokens.get("refresh_token") - if not access_token or not refresh_token: - return None - # Reject expired tokens — importing stale tokens from ~/.codex/ - # that can't be refreshed leaves the user stuck with "Login successful!" - # but no working credentials. - if _codex_access_token_is_expiring(access_token, 0): - logger.debug( - "Codex CLI tokens at %s are expired — skipping import.", auth_path, - ) - return None - return dict(tokens) - except Exception: - return None - - -def resolve_codex_runtime_credentials( - *, - force_refresh: bool = False, - refresh_if_expiring: bool = True, - refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, -) -> Dict[str, Any]: - """Resolve runtime credentials from Hermes's own Codex token store. - - Falls back to the credential pool when the singleton (``providers.openai-codex.tokens``) has no - usable access_token but the pool (``credential_pool.openai-codex``) does. - """ - read_error: Optional[AuthError] = None - try: - data = _read_codex_tokens() - except AuthError as exc: - read_error = exc - if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in { - "codex_auth_missing_access_token", - "codex_auth_missing_refresh_token", - "codex_auth_invalid_shape", - }: - imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error")) - if imported: - data = {"tokens": imported, "last_refresh": imported.get("last_refresh")} - else: - data = None - else: - data = None - - if data is None: - pool_token = _pool_codex_access_token() - if pool_token: - return _codex_runtime_result(pool_token, source="credential_pool", last_refresh=None) - pool_rate_limit = _codex_pool_rate_limit_status() - if pool_rate_limit: - # Before surfacing the persisted cooldown, ask the Codex usage - # endpoint whether the quota actually reset early (banked reset - # redeemed, plan upgraded, window reset upstream). The persisted - # ``last_error_reset_at`` can be days in the future while the - # account is already usable again — see issue #43747. - stale_token = str(pool_rate_limit.get("access_token") or "").strip() - if stale_token and _probe_codex_quota_restored( - stale_token, - base_url=pool_rate_limit.get("base_url"), - ): - logger.info( - "Codex quota restored upstream — clearing stale pool cooldown(s)." - ) - clear_codex_pool_quota_cooldowns() - pool_token = _pool_codex_access_token() - if pool_token: - return _codex_runtime_result(pool_token, source="credential_pool", last_refresh=None) - reset_at = pool_rate_limit.get("reset_at") - remaining = ( - int(reset_at - time.time()) - if isinstance(reset_at, (int, float)) and reset_at > time.time() - else None - ) - raise _codex_quota_exhausted_error(remaining) - if read_error is not None: - raise read_error - raise _codex_err( - "No Codex credentials stored. Run `hermes auth` to authenticate.", - "codex_auth_missing", relogin=True, - ) - - tokens = dict(data["tokens"]) - access_token = str(tokens.get("access_token", "") or "").strip() - refresh_timeout_seconds = env_float("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", 20) - - should_refresh = bool(force_refresh) - if (not should_refresh) and refresh_if_expiring: - should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds) - if should_refresh: - # Re-read under lock to avoid racing with other Hermes processes - with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)): - data = _read_codex_tokens(_lock=False) - tokens = dict(data["tokens"]) - access_token = str(tokens.get("access_token", "") or "").strip() - - should_refresh = bool(force_refresh) - if (not should_refresh) and refresh_if_expiring: - should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds) - - if should_refresh: - tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds) - access_token = str(tokens.get("access_token", "") or "").strip() - - return _codex_runtime_result( - access_token, source="hermes-auth-store", last_refresh=data.get("last_refresh"), - ) - - -def _is_codex_rate_limit_shaped( - code: Any, - reason: Any, - message: Any, -) -> bool: - """True when persisted pool-entry error metadata describes a 429/quota stop.""" - reason_l = str(reason or "").lower() - message_l = str(message or "").lower() - return ( - code == 429 - or "rate_limit" in reason_l - or "usage_limit" in reason_l - or "quota" in reason_l - or "rate limit" in message_l - or "usage limit" in message_l - or "quota" in message_l - ) - - -# Throttle for the live Codex quota probe below. The probe runs on the hot -# credential-selection path while the pool is exhausted, so without a floor a -# busy gateway would hammer the usage endpoint on every model/auxiliary call. -CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS = 300 # 5 minutes -_codex_quota_probe_cache: Dict[str, Tuple[float, Optional[bool]]] = {} -_codex_quota_probe_lock = threading.Lock() - - -def _codex_usage_probe_url(base_url: Optional[str]) -> str: - """Resolve the Codex usage endpoint for a probe. - - Mirrors the Codex CLI's PathStyle split: base URLs containing ``/backend-api`` use the ChatGPT - ``/wham/usage`` path, everything else ``/api/codex/usage``. Kept local so this low-level auth - module does not import the auxiliary account-usage module. - """ - normalized = str(base_url or "").strip().rstrip("/") - if not normalized: - normalized = _codex_base_url() - if normalized.endswith("/codex"): - normalized = normalized[: -len("/codex")] - prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex") - return prefix + "/usage" - - -def _probe_codex_quota_restored( - access_token: Any, - *, - base_url: Optional[str] = None, - min_interval_seconds: float = CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS, -) -> Optional[bool]: - """Ask the Codex usage endpoint whether this account's quota is usable again. - - Probes are throttled per access token (module-local cache) so the hot selection path can fire - this freely. - """ - token = str(access_token or "").strip() - if not token: - return None - # Real Codex access tokens are JWTs. Refusing to probe non-JWT tokens - # avoids pointless network calls for corrupt/placeholder entries (and - # keeps hermetic test fixtures with dummy tokens offline). - if not _decode_jwt_claims(token): - return None - cache_key = hashlib.sha256(token.encode("utf-8")).hexdigest()[:16] - now = time.monotonic() - with _codex_quota_probe_lock: - cached = _codex_quota_probe_cache.get(cache_key) - if cached is not None and (now - cached[0]) < min_interval_seconds: - return cached[1] - # Reserve the slot immediately so concurrent selectors don't stampede - # the endpoint while this probe is in flight. - _codex_quota_probe_cache[cache_key] = (now, None) - - result: Optional[bool] = None - try: - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/json", - "User-Agent": "codex-cli", - } - # Best-effort ChatGPT-Account-Id from the JWT (the backend requires it - # for some account shapes; harmless to omit for others). - claims = _decode_jwt_claims(token) - account_id = ( - claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id") - if isinstance(claims.get("https://api.openai.com/auth"), dict) - else None - ) - if _nonempty_str(account_id): - headers["ChatGPT-Account-Id"] = account_id.strip() - with _codex_http_client(timeout=10.0) as client: - response = client.get(_codex_usage_probe_url(base_url), headers=headers) - if response.status_code == 200: - payload = response.json() or {} - rate_limit = payload.get("rate_limit") or {} - worst_used: Optional[float] = None - for key in ("primary_window", "secondary_window"): - used = (rate_limit.get(key) or {}).get("used_percent") - if isinstance(used, (int, float)): - worst_used = max(worst_used or 0.0, float(used)) - if worst_used is not None: - result = worst_used < 100.0 - elif response.status_code == 429: - result = False - except Exception: - logger.debug("Codex quota probe failed", exc_info=True) - result = None - - with _codex_quota_probe_lock: - _codex_quota_probe_cache[cache_key] = (now, result) - return result - - -def clear_codex_pool_quota_cooldowns(access_token: Optional[str] = None) -> int: - """Clear rate-limit cooldowns on persisted openai-codex pool entries. - - Called after the upstream quota is KNOWN to be restored (a successful ``/usage reset`` - redemption, or a positive live probe) so auth.json stops freezing credentials behind a stale - ``last_error_reset_at``. - - When *access_token* is given, only the matching entry is cleared; otherwise every rate-limited - entry clears (a redeemed banked reset restores the whole account, and any entry that is - genuinely still exhausted just re-freezes with fresh metadata on its next 429). - """ - cleared = 0 - try: - with _auth_store_lock(): - auth_store = _load_auth_store() - entries = _pool_entries(auth_store, "openai-codex") - if entries is None: - return 0 - for entry in entries: - if not isinstance(entry, dict): - continue - if entry.get("last_status") != "exhausted": - continue - if access_token and str(entry.get("access_token") or "") != access_token: - continue - if not _is_codex_rate_limit_shaped( - entry.get("last_error_code"), - entry.get("last_error_reason"), - entry.get("last_error_message"), - ): - continue - _clear_pool_entry_status(entry) - cleared += 1 - if cleared: - _save_auth_store(auth_store) - except Exception: - logger.debug("Failed to clear Codex pool quota cooldowns", exc_info=True) - return cleared - - -def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]: - """Return metadata for a pool-only Codex credential in quota cooldown.""" - def _parse_reset_at(value: Any) -> Optional[float]: - if value is None or value == "": - return None - if isinstance(value, (int, float)): - numeric = float(value) - if numeric <= 0: - return None - return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric - if isinstance(value, str): - raw = value.strip() - if not raw: - return None - try: - numeric = float(raw) - except ValueError: - numeric = None - if numeric is not None: - return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric - try: - return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() - except ValueError: - return None - return None - - try: - with _auth_store_lock(): - auth_store = _load_auth_store() - entries = _pool_entries(auth_store, "openai-codex") - if entries is None: - return None - now = time.time() - for entry in entries: - if not isinstance(entry, dict): - continue - token = entry.get("access_token") - if not _nonempty_str(token): - continue - if entry.get("last_status") != "exhausted": - continue - if not _is_codex_rate_limit_shaped( - entry.get("last_error_code"), - entry.get("last_error_reason"), - entry.get("last_error_message"), - ): - continue - reset_at = _parse_reset_at(entry.get("last_error_reset_at")) - if reset_at is not None and reset_at <= now: - continue - return { - "label": entry.get("label"), - "last_refresh": entry.get("last_refresh"), - "reset_at": reset_at, - "reason": entry.get("last_error_reason"), - "message": entry.get("last_error_message"), - "access_token": token.strip(), - "base_url": entry.get("base_url"), - } - except Exception: - logger.debug("Codex pool rate-limit lookup failed", exc_info=True) - return None - - -def _pool_entries(auth_store: Dict[str, Any], provider_id: str) -> Optional[List[Any]]: - """``auth_store["credential_pool"][provider_id]`` when it is a list, else None.""" - pool = auth_store.get("credential_pool") - entries = pool.get(provider_id) if isinstance(pool, dict) else None - return entries if isinstance(entries, list) else None - - -def _pool_codex_access_token() -> str: - """Return the most-recent usable access_token from the openai-codex pool. - - Used as a fallback by ``resolve_codex_runtime_credentials`` when the singleton has no creds. - Reads ``credential_pool.openai-codex`` entries directly from auth.json and picks the first non- - empty access_token, preferring entries that are not currently in an exhaustion cooldown. - """ - try: - with _auth_store_lock(): - auth_store = _load_auth_store() - entries = _pool_entries(auth_store, "openai-codex") - if entries is None: - return "" - - def _entry_usable(entry: Dict[str, Any]) -> bool: - if not isinstance(entry, dict): - return False - token = entry.get("access_token") - if not _nonempty_str(token): - return False - # Skip entries currently in an exhaustion cooldown window. - reset_at = entry.get("last_error_reset_at") - return not (isinstance(reset_at, (int, float)) and reset_at > time.time()) - - for entry in entries: - if _entry_usable(entry): - return str(entry.get("access_token", "")).strip() - except Exception: - logger.debug("Codex pool fallback lookup failed", exc_info=True) - return "" - # ============================================================================= # xAI Grok OAuth — tokens stored in ~/.hermes/auth.json # ============================================================================= -def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Return usable xAI OAuth state from provider state or credential pool.""" - state = _load_provider_state(auth_store, "xai-oauth") - tokens = state.get("tokens") if isinstance(state, dict) else None - if isinstance(tokens, dict): - access_token = str(tokens.get("access_token", "") or "").strip() - refresh_token = str(tokens.get("refresh_token", "") or "").strip() - if access_token and refresh_token: - return state - - credential_pool = auth_store.get("credential_pool") - entries = ( - credential_pool.get("xai-oauth") - if isinstance(credential_pool, dict) - else None - ) - if isinstance(entries, list): - for entry in entries: - if not isinstance(entry, dict): - continue - access_token = str(entry.get("access_token", "") or "").strip() - refresh_token = str(entry.get("refresh_token", "") or "").strip() - if not access_token or not refresh_token: - continue - merged = dict(state or {}) - merged["tokens"] = { - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": str(entry.get("token_type") or "Bearer"), - } - if entry.get("last_refresh"): - merged["last_refresh"] = entry.get("last_refresh") - merged.setdefault("auth_mode", "oauth_pkce") - return merged - - return state if isinstance(state, dict) else None - - -def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool: - tokens = state.get("tokens") if isinstance(state, dict) else None - return ( - isinstance(tokens, dict) - and bool(str(tokens.get("access_token", "") or "").strip()) - and bool(str(tokens.get("refresh_token", "") or "").strip()) - ) - - -def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]: - auth_store = _load_auth_store_maybe_locked(_lock) - state = _xai_oauth_state_from_store(auth_store) - if not _xai_oauth_state_has_usable_tokens(state): - global_state = _xai_oauth_state_from_store(_load_global_auth_store()) - if _xai_oauth_state_has_usable_tokens(global_state): - state = global_state - if not state: - raise _xai_err( - "No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.", - "xai_auth_missing", relogin=True, - ) - tokens = state.get("tokens") - if not isinstance(tokens, dict): - raise _xai_err( - "xAI OAuth state is missing tokens. Re-authenticate with `hermes model`.", - "xai_auth_invalid_shape", relogin=True, - ) - access_token = str(tokens.get("access_token", "") or "").strip() - refresh_token = str(tokens.get("refresh_token", "") or "").strip() - if not access_token: - raise _xai_err( - "xAI OAuth state is missing access_token. Re-authenticate with `hermes model`.", - "xai_auth_missing_access_token", relogin=True, - ) - if not refresh_token: - raise _xai_err( - "xAI OAuth state is missing refresh_token. Re-authenticate with `hermes model`.", - "xai_auth_missing_refresh_token", relogin=True, - ) - return { - "tokens": tokens, - "last_refresh": state.get("last_refresh"), - "discovery": state.get("discovery") or {}, - "redirect_uri": state.get("redirect_uri"), - } - - -def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None: - """Persist a rotated xAI OAuth ``state`` into the global-root auth.json. - - Best-effort write-through for the multi-profile rotation hazard (#43589): xAI rotates the - refresh_token on every refresh, so when a profile session refreshes a grant it resolved from the - root fallback, the rotated chain must land back in root. - - Only updates ``providers.xai-oauth`` in the root store; never touches the profile store (the - caller already saved that). Swallows all errors — a failed write-through degrades to the pre- - existing behavior (root stale), it must never break the profile's own successful save. - """ - global_path = _global_auth_file_path() - if global_path is None: - # Classic mode (profile == root); the profile save already hit root. - return - # Seat belt: under pytest, refuse to write the real user's - # ~/.hermes/auth.json even when HERMES_HOME points at a profile path - # (mirrors the read-side guard in _load_global_auth_store). Uses the - # unmodified HOME env, not Path.home() which fixtures may monkeypatch. - if os.environ.get("PYTEST_CURRENT_TEST"): - real_home_env = os.environ.get("HOME", "") - if real_home_env: - real_root = Path(real_home_env) / ".hermes" / "auth.json" - try: - if global_path.resolve(strict=False) == real_root.resolve(strict=False): - return - except Exception: - return - try: - _persist_provider_state_to_store( - "xai-oauth", - state, - global_path, - set_active=False, - ) - except Exception as exc: # pragma: no cover - best effort - logger.debug("xAI OAuth: write-through to global root failed: %s", exc) - - -def _save_xai_oauth_tokens( - tokens: Dict[str, Any], - *, - discovery: Optional[Dict[str, Any]] = None, - redirect_uri: str = "", - last_refresh: Optional[str] = None, - auth_mode: str = "oauth_device_code", - set_active: bool = True, -) -> None: - """Persist xAI OAuth tokens into the auth store. - - When *set_active* is True (default), also promote ``xai-oauth`` to ``active_provider`` — - appropriate for intentional model/auth login. Pass ``set_active=False`` for side-tool credential - bootstrap (TTS/setup, tools config, dashboard token save, token refresh) so inference routing is - unchanged. - """ - if last_refresh is None: - last_refresh = _utc_now_z() - with _auth_store_lock(): - auth_store = _load_auth_store() - # A profile that lacks its own xai-oauth block is reading the root - # grant through _load_provider_state's fallback. When such a profile - # refreshes the (rotating) grant, we must write the rotated chain back - # to root too, or root is left holding a revoked refresh token (#43589). - # #74339: the old key-presence check (_profile_has_own_xai_oauth_state) - # decided write-through based on whether the profile had a - # providers.xai-oauth key BEFORE the save — but _store_provider_state - # unconditionally creates that key below. Use - # _load_provider_state_with_source to learn where the grant was - # resolved from and write back only to that source. - state, source_path = _load_provider_state_with_source( - auth_store, "xai-oauth" - ) - if state is None: - state = {} - state["tokens"] = tokens - state["last_refresh"] = last_refresh - state["auth_mode"] = auth_mode - if discovery: - state["discovery"] = discovery - if redirect_uri: - state["redirect_uri"] = redirect_uri - global_root = _global_auth_file_path() - is_from_root = bool( - source_path is not None - and global_root is not None - and _same_path(source_path, global_root) - ) - if is_from_root: - # Grant was resolved from root — write back to root only. - # Do NOT call _store_provider_state on the profile auth_store - # (it would create a shadowing providers.xai-oauth key that - # disables write-through on the next refresh — #74339). - _write_through_xai_oauth_to_global_root(state) - else: - # Profile genuinely owns this — write to profile store. - _store_provider_state( - auth_store, "xai-oauth", state, set_active=set_active - ) - _save_auth_store(auth_store) - - -def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool: - if not isinstance(access_token, str) or "." not in access_token: - return False - try: - parts = access_token.split(".") - if len(parts) < 2: - return False - payload_b64 = parts[1] - payload_b64 += "=" * (-len(payload_b64) % 4) - payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8")) - exp = payload.get("exp") - if not isinstance(exp, (int, float)): - return False - return float(exp) <= (time.time() + max(0, int(skew_seconds))) - except Exception: - return False - - -def _xai_proactive_refresh_skew_seconds(access_token: str) -> int: - """How far before JWT ``exp`` to proactively refresh xAI OAuth tokens. - - SuperGrok sessions ship multi-hour tokens where the gateway-oriented hour-long skew makes sense, - but device-code logins often return ~15-minute JWTs; the full skew would force a refresh on - every credential resolution, burning single-use refresh tokens and racing concurrent callers - into ``invalid_grant`` quarantine. - """ - max_skew = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS - if not isinstance(access_token, str) or "." not in access_token: - return max_skew - try: - parts = access_token.split(".") - if len(parts) < 2: - return max_skew - payload_b64 = parts[1] - payload_b64 += "=" * (-len(payload_b64) % 4) - payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8")) - exp = payload.get("exp") - if not isinstance(exp, (int, float)): - return max_skew - remaining = float(exp) - time.time() - if remaining <= 0: - return max_skew - if remaining <= 45 * 60: - return min(120, max_skew) - return max_skew - except Exception: - return max_skew - - -def _is_xai_origin_host(host: str) -> bool: - """``x.ai`` is the bare apex, so an exact match or any ``.x.ai`` suffix is accepted.""" - return host == "x.ai" or host.endswith(".x.ai") - - -def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str: - """Refuse any OIDC discovery endpoint that isn't HTTPS on the xAI origin. - - The discovery result is cached in auth.json, so a single MITM at login could plant a malicious - ``token_endpoint`` that receives the refresh_token forever. Pinning scheme + host (RFC 8414 §2: - HTTPS issuer, same-origin token_endpoint) removes that persistence; ``x.ai`` is the bare apex, - so an exact match or any ``.x.ai`` suffix is accepted. - """ - parsed = urlparse(url) - if parsed.scheme != "https": - raise _xai_err( - f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}.", - "xai_discovery_invalid", - ) - host = (parsed.hostname or "").lower() - if not host: - raise _xai_err( - f"xAI OIDC discovery {field} is missing a hostname: {url!r}.", - "xai_discovery_invalid", - ) - if not _is_xai_origin_host(host): - raise _xai_err( - f"xAI OIDC discovery {field} host {host!r} is not on the xAI origin " - f"(expected x.ai or a *.x.ai subdomain). Refusing to use a cached " - f"endpoint that may have been substituted by a MITM during initial " - f"discovery; re-authenticate with `hermes model` to re-fetch.", - "xai_discovery_invalid", - ) - return url - - -def _xai_validate_inference_base_url(value: str, *, fallback: str) -> str: - """Refuse a non-xAI base_url for the OAuth-authenticated inference path. - - Pin the inference origin to ``api.x.ai`` (or any ``*.x.ai`` subdomain xAI may add). On - rejection, fall back to the default and log a warning rather than raise — a bad env var should - not deadlock authentication, but it should also never leak the bearer. - - ``value`` is the already-stripped, trailing-slash-trimmed candidate from env. Empty input - returns ``fallback`` unchanged. - """ - candidate = (value or "").strip().rstrip("/") - if not candidate: - return fallback - try: - parsed = urlparse(candidate) - except Exception: - logger.warning( - "Ignoring malformed xAI base_url override %r; using %s instead.", - candidate, fallback, - ) - return fallback - if parsed.scheme != "https": - logger.warning( - "Refusing non-HTTPS xAI base_url override %r (xai-oauth bearer would " - "be sent in cleartext); falling back to %s.", - candidate, fallback, - ) - return fallback - host = (parsed.hostname or "").lower() - if not host: - logger.warning( - "Ignoring xAI base_url override %r with no hostname; using %s instead.", - candidate, fallback, - ) - return fallback - if not _is_xai_origin_host(host): - logger.warning( - "Refusing xAI base_url override %r — host %r is not on the xAI origin " - "(expected x.ai or a *.x.ai subdomain). The xai-oauth bearer is only " - "valid against xAI's inference API; sending it elsewhere would leak " - "the credential. Falling back to %s.", - candidate, host, fallback, - ) - return fallback - return candidate - - -def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]: - try: - response = httpx.get( - XAI_OAUTH_DISCOVERY_URL, - headers={"Accept": "application/json"}, - timeout=timeout_seconds, - ) - except Exception as exc: - raise _xai_err(f"xAI OIDC discovery failed: {exc}", "xai_discovery_failed") from exc - if response.status_code != 200: - raise _xai_err( - f"xAI OIDC discovery returned status {response.status_code}.", - "xai_discovery_failed", - ) - try: - payload = response.json() - except Exception as exc: - raise _xai_err( - f"xAI OIDC discovery returned invalid JSON: {exc}", - "xai_discovery_invalid_json", - ) from exc - if not isinstance(payload, dict): - raise _xai_err( - "xAI OIDC discovery response was not a JSON object.", - "xai_discovery_incomplete", - ) - authorization_endpoint = str(payload.get("authorization_endpoint", "") or "").strip() - token_endpoint = str(payload.get("token_endpoint", "") or "").strip() - if not authorization_endpoint or not token_endpoint: - raise _xai_err( - "xAI OIDC discovery response was missing required endpoints.", - "xai_discovery_incomplete", - ) - _xai_validate_oauth_endpoint(authorization_endpoint, field="authorization_endpoint") - _xai_validate_oauth_endpoint(token_endpoint, field="token_endpoint") - return { - "authorization_endpoint": authorization_endpoint, - "token_endpoint": token_endpoint, - } - - -def _xai_tokens_from_payload(payload: Dict[str, Any], access_token: str, fallback_refresh: str) -> Dict[str, Any]: - """Token block persisted for xAI OAuth; falls back to *fallback_refresh* when none is rotated in.""" - return { - "access_token": access_token, - "refresh_token": str(payload.get("refresh_token") or fallback_refresh).strip(), - "id_token": str(payload.get("id_token") or "").strip(), - "expires_in": payload.get("expires_in"), - "token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer", - } - - -def refresh_xai_oauth_pure( - access_token: str, - refresh_token: str, - *, - token_endpoint: str = "", - timeout_seconds: float = 20.0, -) -> Dict[str, Any]: - del access_token - if not _nonempty_str(refresh_token): - raise _xai_err( - "xAI OAuth is missing refresh_token. Re-authenticate with `hermes model`.", - "xai_auth_missing_refresh_token", relogin=True, - ) - endpoint = token_endpoint.strip() or _xai_oauth_discovery(timeout_seconds)["token_endpoint"] - # Re-validate cached endpoints on the refresh hot path: an auth.json - # written by an older Hermes (or hand-edited) may carry a non-xAI - # token_endpoint that would receive every future refresh_token in - # plaintext if we trusted it blindly. Cheap suffix check; fast-fail - # with a clear error so the user can re-run `hermes model` to refetch. - _xai_validate_oauth_endpoint(endpoint, field="token_endpoint") - timeout = httpx.Timeout(max(5.0, float(timeout_seconds))) - with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client: - response = client.post( - endpoint, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={ - "grant_type": "refresh_token", - "client_id": XAI_OAUTH_CLIENT_ID, - "refresh_token": refresh_token, - }, - ) - if response.status_code != 200: - detail = response.text.strip() - # ``403`` from xAI's token endpoint is almost always a tier / - # entitlement gate (the OAuth grant exists but the account isn't - # on the allowlist for API access). Re-running ``hermes model`` - # won't fix that — surface a separate error code so - # ``format_auth_error`` doesn't append a misleading - # re-authenticate hint, and point users at the ``XAI_API_KEY`` - # fallback. See #26847. - if response.status_code == 403: - raise _xai_err( - "xAI token refresh failed with HTTP 403." - + (f" Response: {detail}" if detail else "") - + " This OAuth account is not authorized for xAI API" - " access — xAI may be restricting API/OAuth use to" - " specific SuperGrok tiers despite the in-app" - " subscription being active. Re-logging in won't" - " change that; set ``XAI_API_KEY`` and switch to" - " ``provider: xai`` (API-key path) if available, or" - " upgrade your subscription at https://x.ai/grok.", - "xai_oauth_tier_denied", relogin=False, - ) - raise _xai_err( - "xAI token refresh failed." - + (f" Response: {detail}" if detail else ""), - "xai_refresh_failed", relogin=response.status_code in {400, 401}, - ) - payload, refreshed_access = _refresh_payload_access_token( - response, - provider="xai-oauth", - invalid_json=("xAI token refresh returned invalid JSON: {exc}", "xai_refresh_invalid_json"), - invalid_json_relogin=False, - strict_str=False, - invalid_response=( - "xAI token refresh response was not a JSON object.", - "xai_refresh_invalid_response", - ), - missing_access=( - "xAI token refresh response was missing access_token.", - "xai_refresh_missing_access_token", - ), - ) - return { - **_xai_tokens_from_payload(payload, refreshed_access, refresh_token), - "last_refresh": _utc_now_z(), - } - - -def _refresh_xai_oauth_tokens( - tokens: Dict[str, Any], - *, - token_endpoint: str, - redirect_uri: str = "", - timeout_seconds: float, -) -> Dict[str, Any]: - # Re-persist whatever auth_mode is already stored (legacy pre-device-code - # logins may still carry ``oauth_pkce``): the refresh hot path must not - # relabel how the grant was originally obtained. - try: - state = _load_provider_state(_load_auth_store(), "xai-oauth") or {} - auth_mode = str(state.get("auth_mode") or "oauth_device_code") - except Exception: - auth_mode = "oauth_device_code" - refreshed = refresh_xai_oauth_pure( - str(tokens.get("access_token", "") or ""), - str(tokens.get("refresh_token", "") or ""), - token_endpoint=token_endpoint, - timeout_seconds=timeout_seconds, - ) - updated_tokens = dict(tokens) - updated_tokens["access_token"] = refreshed["access_token"] - updated_tokens["refresh_token"] = refreshed["refresh_token"] - if refreshed.get("id_token"): - updated_tokens["id_token"] = refreshed["id_token"] - if refreshed.get("expires_in") is not None: - updated_tokens["expires_in"] = refreshed["expires_in"] - if refreshed.get("token_type"): - updated_tokens["token_type"] = refreshed["token_type"] - _save_xai_oauth_tokens( - updated_tokens, - discovery={"token_endpoint": token_endpoint}, - redirect_uri=redirect_uri, - last_refresh=refreshed["last_refresh"], - auth_mode=auth_mode, - # Refresh must not flip active_provider — TTS/side tools can refresh - # xAI tokens while chat still routes through another provider. - set_active=False, - ) - return updated_tokens - - -def _quarantine_xai_oauth_tokens(exc: AuthError) -> None: - """Clear dead xAI tokens from auth.json after a terminal refresh failure. - - Terminal = HTTP 400/401/403 (invalid_grant, token revoked). Subsequent sessions then fail fast - without a network retry. Mirrors credential_pool.py quarantine. Best-effort: persistence - failures are logged and swallowed (caller re-raises the original error regardless). - """ - try: - _q_store = _load_auth_store() - _q_state = _load_provider_state(_q_store, "xai-oauth") or {} - _q_tokens = dict(_q_state.get("tokens") or {}) - _q_tokens.pop("access_token", None) - _q_tokens.pop("refresh_token", None) - _q_state["tokens"] = _q_tokens - _q_state["last_auth_error"] = _last_auth_error_marker( - "xai-oauth", exc, - reason="runtime_refresh_failure", default_code="xai_refresh_failed", - ) - _store_provider_state(_q_store, "xai-oauth", _q_state, set_active=False) - _save_auth_store(_q_store) - except Exception as _save_exc: - logger.debug( - "xAI OAuth: failed to persist quarantined state: %s", _save_exc, - ) - - -def _xai_oauth_inference_base_url() -> str: - return _xai_validate_inference_base_url( - os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/") - or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"), - fallback=DEFAULT_XAI_OAUTH_BASE_URL, - ) - - -def resolve_xai_oauth_runtime_credentials( - *, - force_refresh: bool = False, - refresh_if_expiring: bool = True, - refresh_skew_seconds: Optional[int] = None, -) -> Dict[str, Any]: - def _view(data: Dict[str, Any]) -> tuple[Dict[str, Any], str, str, str, bool]: - tokens = dict(data["tokens"]) - access_token = str(tokens.get("access_token", "") or "").strip() - discovery = dict(data.get("discovery") or {}) - token_endpoint = str(discovery.get("token_endpoint", "") or "").strip() - redirect_uri = str(data.get("redirect_uri", "") or "").strip() - effective_skew = ( - int(refresh_skew_seconds) - if refresh_skew_seconds is not None - else _xai_proactive_refresh_skew_seconds(access_token) - ) - should_refresh = bool(force_refresh) - if (not should_refresh) and refresh_if_expiring: - should_refresh = _xai_access_token_is_expiring(access_token, effective_skew) - return tokens, access_token, token_endpoint, redirect_uri, should_refresh - - data = _read_xai_oauth_tokens() - refresh_timeout_seconds = env_float("HERMES_XAI_REFRESH_TIMEOUT_SECONDS", 20) - tokens, access_token, token_endpoint, redirect_uri, should_refresh = _view(data) - if should_refresh: - with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)): - data = _read_xai_oauth_tokens(_lock=False) - tokens, access_token, token_endpoint, redirect_uri, should_refresh = _view(data) - if should_refresh: - if not token_endpoint: - token_endpoint = _xai_oauth_discovery(refresh_timeout_seconds)["token_endpoint"] - try: - tokens = _refresh_xai_oauth_tokens( - tokens, - token_endpoint=token_endpoint, - redirect_uri=redirect_uri, - timeout_seconds=refresh_timeout_seconds, - ) - access_token = str(tokens.get("access_token", "") or "").strip() - except AuthError as exc: - if _is_terminal_xai_oauth_refresh_error(exc): - _quarantine_xai_oauth_tokens(exc) - raise - - base_url = _xai_oauth_inference_base_url() - return { - "provider": "xai-oauth", - "base_url": base_url, - "api_key": access_token, - "source": "hermes-auth-store", - "last_refresh": data.get("last_refresh"), - # Display/telemetry only. Device-code is the only supported xAI OAuth - # flow, so report it unconditionally — auth.json may still carry a - # legacy ``oauth_pkce`` label, which the refresh path preserves as-is. - "auth_mode": "oauth_device_code", - } - # ============================================================================= # TLS verification helper @@ -8047,836 +6204,8 @@ def _offer_existing_oauth_credentials( return False -def _login_openai_codex( - args, - pconfig: ProviderConfig, - *, - force_new_login: bool = False, -) -> None: - """OpenAI Codex login via device code flow. Tokens stored in ~/.hermes/auth.json.""" - - del args, pconfig # kept for parity with other provider login helpers - - # Check for existing Hermes-owned credentials - if not force_new_login and _offer_existing_oauth_credentials( - "openai-codex", - resolve=resolve_codex_runtime_credentials, - is_expiring=_codex_access_token_is_expiring, - display_name="Codex", - default_base_url=DEFAULT_CODEX_BASE_URL, - expired_notice="Existing Codex credentials are expired. Starting fresh login...", - ): - return - - # Check for existing Codex CLI tokens we can import - if not force_new_login: - cli_tokens = _import_codex_cli_tokens() - if cli_tokens: - print("Found existing Codex CLI credentials at ~/.codex/auth.json") - print("Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.") - if _prompt_yes_no( - "Import these credentials? (a separate login is recommended) [y/N]: ", default="n", - ): - _save_codex_tokens(cli_tokens) - config_path = _update_config_for_provider("openai-codex", _codex_base_url()) - print() - print("Credentials imported. Note: if Codex CLI refreshes its token,") - print("Hermes will keep working independently with its own session.") - print(f" Config updated: {config_path} (model.provider=openai-codex)") - return - - # Run a fresh device code flow — Hermes gets its own OAuth session - print() - print("Signing in to OpenAI Codex...") - print("(Hermes creates its own session — won't affect Codex CLI or VS Code)") - print() - - creds = _codex_device_code_login() - - # Save tokens to Hermes auth store - _save_codex_tokens(creds["tokens"], creds.get("last_refresh")) - config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL)) - _print_login_success("openai-codex", config_path, show_auth_state=True) - - -def _login_xai_oauth( - args, - pconfig: ProviderConfig, - *, - force_new_login: bool = False, -) -> None: - del pconfig - - if not force_new_login and _offer_existing_oauth_credentials( - "xai-oauth", - resolve=resolve_xai_oauth_runtime_credentials, - is_expiring=_xai_access_token_is_expiring, - display_name="xAI OAuth", - default_base_url=DEFAULT_XAI_OAUTH_BASE_URL, - ): - return - - print() - print("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...") - print("(Hermes creates its own local OAuth session)") - print() - - timeout_seconds = float(getattr(args, "timeout", None) or 20.0) - open_browser = not getattr(args, "no_browser", False) - if _is_remote_session(): - open_browser = False - - creds = _xai_oauth_device_code_login( - timeout_seconds=timeout_seconds, - open_browser=open_browser, - ) - _save_xai_oauth_tokens( - creds["tokens"], - discovery=creds.get("discovery"), - redirect_uri=creds.get("redirect_uri", ""), - last_refresh=creds.get("last_refresh"), - auth_mode="oauth_device_code", - ) - # An explicit interactive re-login is a strong signal the user wants the - # xAI credential re-enabled. ``hermes auth remove xai-oauth`` leaves a - # ``device_code`` suppression marker that otherwise stops the singleton - # seed from re-creating the pool entry, so ``hermes auth list`` would show - # nothing even though the agent still works via the singleton fallback. - # Clear it here (same helper ``auth_add_command`` uses). This is kept OUT - # of ``_save_xai_oauth_tokens`` on purpose — that helper is shared with the - # refresh hot path, which must never mutate suppression state. - unsuppress_credential_source("xai-oauth", "device_code") - config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL)) - _print_login_success("xai-oauth", config_path, show_auth_state=True) - - -def _xai_oauth_request_device_code( - client: httpx.Client, - *, - scope: str = XAI_OAUTH_SCOPE, -) -> Dict[str, Any]: - response = client.post( - XAI_OAUTH_DEVICE_CODE_URL, - headers=_FORM_JSON_HEADERS, - data={ - "client_id": XAI_OAUTH_CLIENT_ID, - "scope": scope, - }, - ) - if response.status_code != 200: - raise _xai_err( - f"xAI device-code request failed (HTTP {response.status_code})." - + (f" Response: {response.text.strip()}" if response.text else ""), - "device_code_request_failed", - ) - payload = response.json() - required = ( - "device_code", - "user_code", - "verification_uri", - "verification_uri_complete", - "expires_in", - "interval", - ) - missing = [key for key in required if key not in payload] - if missing: - raise _xai_err( - f"xAI device-code response missing fields: {', '.join(missing)}", - "device_code_invalid", - ) - return payload - - -def _xai_oauth_poll_device_token( - client: httpx.Client, - *, - token_endpoint: str, - device_code: str, - expires_in: int, - poll_interval: int, -) -> Dict[str, Any]: - def _validate(payload: Dict[str, Any]) -> None: - for field_name, article in (("access_token", "an"), ("refresh_token", "a")): - if not payload.get(field_name): - raise _xai_err( - f"xAI device-code token response did not include {article} {field_name}.", - "xai_device_token_invalid", - ) - - def _error(response, error_payload) -> Exception: - description = ( - error_payload.get("error_description") - or error_payload.get("error") - or response.text - ) - return _xai_err( - f"xAI device-code token polling failed: {description}", - "xai_device_token_failed", - ) - - return _poll_device_token_generic( - lambda: client.post( - token_endpoint, - headers=_FORM_JSON_HEADERS, - data={ - "grant_type": DEVICE_CODE_GRANT_TYPE, - "client_id": XAI_OAUTH_CLIENT_ID, - "device_code": device_code, - }, - ), - expires_in=int(expires_in), - poll_interval=max(1, int(poll_interval)), - validate_success=_validate, - on_non_json_error=lambda _r: _xai_err( - "xAI device-code token polling returned a non-JSON error response.", - "xai_device_token_failed", - ), - on_error=_error, - on_timeout=lambda: _xai_err( - "Timed out waiting for xAI device authorization.", - "device_code_timeout", - ), - ) - - -def _xai_oauth_device_code_login( - *, - timeout_seconds: float = 20.0, - open_browser: bool = True, -) -> Dict[str, Any]: - discovery = _xai_oauth_discovery(timeout_seconds) - token_endpoint = discovery["token_endpoint"] - timeout = httpx.Timeout(max(20.0, timeout_seconds)) - with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client: - device_data = _xai_oauth_request_device_code(client) - verification_url = str( - device_data.get("verification_uri_complete") - or device_data["verification_uri"] - ) - user_code = str(device_data["user_code"]) - expires_in = int(device_data["expires_in"]) - interval = int(device_data["interval"]) - - _print_device_code_instructions( - verification_url, - user_code, - open_browser=open_browser and not _is_remote_session() and _can_open_graphical_browser(), - swallow_open_errors=True, - ) - print(f"Waiting for approval (polling every {max(1, interval)}s)...") - - payload = _xai_oauth_poll_device_token( - client, - token_endpoint=token_endpoint, - device_code=str(device_data["device_code"]), - expires_in=expires_in, - poll_interval=interval, - ) - - access_token = str(payload.get("access_token", "") or "").strip() - refresh_token = str(payload.get("refresh_token", "") or "").strip() - if not access_token or not refresh_token: - raise _xai_err( - "xAI device-code token response was missing required tokens.", - "xai_device_token_invalid", - ) - base_url = _xai_oauth_inference_base_url() - return { - "tokens": _xai_tokens_from_payload(payload, access_token, refresh_token), - "discovery": discovery, - "redirect_uri": "", - "base_url": base_url, - "last_refresh": _utc_now_z(), - "source": "oauth-device-code", - } - - -def _codex_login_rate_limited_error(response: "httpx.Response", *, during: str = "") -> AuthError: - """AuthError for a 429 from OpenAI's device-auth endpoints (a throttle, not a credential fault).""" - retry_after = _parse_retry_after_seconds(getattr(response, "headers", None)) - wait_hint = ( - f" Try again in about {retry_after}s." - if retry_after is not None - else " Wait a minute and run the login again." - ) - return _codex_err( - f"OpenAI is rate-limiting Codex login requests (HTTP 429){during}. " - "This is a temporary throttle on OpenAI's side, not a credential " - f"problem.{wait_hint}", - CODEX_RATE_LIMITED_CODE, - ) - - -def _codex_request_device_code(issuer: str, client_id: str) -> Dict[str, Any]: - """Step 1 of the Codex device flow: request a user code, retrying capped on HTTP 429.""" - # OpenAI's auth endpoint rate-limits this request (HTTP 429) when login is - # attempted too often from the same IP/account — retry with capped backoff - # (honoring ``Retry-After``) before surfacing a clear, actionable message. - resp = None - max_attempts = 4 - for attempt in range(1, max_attempts + 1): - try: - with _codex_http_client(timeout=httpx.Timeout(15.0)) as client: - resp = client.post( - f"{issuer}/api/accounts/deviceauth/usercode", - json={"client_id": client_id}, - headers={"Content-Type": "application/json"}, - ) - except Exception as exc: - raise _codex_err(f"Failed to request device code: {exc}", "device_code_request_failed") - - if resp.status_code != 429: - break - - if attempt < max_attempts: - retry_after = _parse_retry_after_seconds( - getattr(resp, "headers", None) - ) - # Exponential backoff (2s, 4s, 8s) capped, preferring the - # server-provided Retry-After when present. - delay = retry_after if retry_after is not None else 2 ** attempt - delay = max(1, min(int(delay), 60)) - print( - "OpenAI is rate-limiting login requests " - f"(429); retrying in {delay}s..." - ) - time.sleep(delay) - - if resp is not None and resp.status_code == 429: - raise _codex_login_rate_limited_error(resp) - - if resp is None or resp.status_code != 200: - status = resp.status_code if resp is not None else "unknown" - raise _codex_err( - f"Device code request returned status {status}.", - "device_code_request_error", - ) - - device_data = resp.json() - device_data["interval"] = max(3, int(device_data.get("interval", "5"))) - if not device_data.get("user_code", "") or not device_data.get("device_auth_id", ""): - raise _codex_err("Device code response missing required fields.", "device_code_incomplete") - return device_data - - -def _codex_poll_authorization_code( - issuer: str, *, device_auth_id: str, user_code: str, poll_interval: int, -) -> Dict[str, Any]: - """Step 3 of the Codex device flow: poll until sign-in completes (403/404 = still pending).""" - max_wait = 15 * 60 # 15 minutes - start = time.monotonic() - code_resp = None - - try: - with _codex_http_client(timeout=httpx.Timeout(15.0)) as client: - while time.monotonic() - start < max_wait: - time.sleep(poll_interval) - poll_resp = client.post( - f"{issuer}/api/accounts/deviceauth/token", - json={"device_auth_id": device_auth_id, "user_code": user_code}, - headers={"Content-Type": "application/json"}, - ) - - if poll_resp.status_code == 200: - code_resp = poll_resp.json() - break - elif poll_resp.status_code in {403, 404}: - continue # User hasn't completed login yet - else: - raise _codex_err( - f"Device auth polling returned status {poll_resp.status_code}.", - "device_code_poll_error", - ) - except KeyboardInterrupt: - print("\nLogin cancelled.") - raise SystemExit(130) - - if code_resp is None: - raise _codex_err("Login timed out after 15 minutes.", "device_code_timeout") - return code_resp - - -def _codex_exchange_authorization_code( - issuer: str, client_id: str, code_resp: Dict[str, Any], -) -> Dict[str, Any]: - """Step 4 of the Codex device flow: swap the authorization code for tokens.""" - authorization_code = code_resp.get("authorization_code", "") - code_verifier = code_resp.get("code_verifier", "") - redirect_uri = f"{issuer}/deviceauth/callback" - - if not authorization_code or not code_verifier: - raise _codex_err( - "Device auth response missing authorization_code or code_verifier.", - "device_code_incomplete_exchange", - ) - - try: - with _codex_http_client(timeout=httpx.Timeout(15.0)) as client: - token_resp = client.post( - CODEX_OAUTH_TOKEN_URL, - data={ - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": redirect_uri, - "client_id": client_id, - "code_verifier": code_verifier, - }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - except Exception as exc: - raise _codex_err(f"Token exchange failed: {exc}", "token_exchange_failed") - - if token_resp.status_code == 429: - raise _codex_login_rate_limited_error(token_resp, during=" during token exchange") - - if token_resp.status_code != 200: - raise _codex_err( - f"Token exchange returned status {token_resp.status_code}.", - "token_exchange_error", - ) - - tokens = token_resp.json() - if not tokens.get("access_token", ""): - raise _codex_err( - "Token exchange did not return an access_token.", - "token_exchange_no_access_token", - ) - return tokens - - -def _codex_device_code_login() -> Dict[str, Any]: - """Run the OpenAI device code login flow and return credentials dict.""" - issuer = "https://auth.openai.com" - client_id = CODEX_OAUTH_CLIENT_ID - - device_data = _codex_request_device_code(issuer, client_id) - user_code = device_data["user_code"] - device_auth_id = device_data["device_auth_id"] - poll_interval = device_data["interval"] - - # Step 2: Show user the code - print("To continue, follow these steps:\n") - print(" 1. Open this URL in your browser:") - print(f" \033[94m{issuer}/codex/device\033[0m\n") - print(" 2. Enter this code:") - print(f" \033[94m{user_code}\033[0m\n") - print("Waiting for sign-in... (press Ctrl+C to cancel)") - - code_resp = _codex_poll_authorization_code( - issuer, device_auth_id=device_auth_id, user_code=user_code, poll_interval=poll_interval, - ) - tokens = _codex_exchange_authorization_code(issuer, client_id, code_resp) - - # Return tokens for the caller to persist (no longer writes to ~/.codex/) - return { - "tokens": { - "access_token": tokens.get("access_token", ""), - "refresh_token": tokens.get("refresh_token", ""), - }, - "base_url": _codex_base_url(), - "last_refresh": _utc_now_z(), - "auth_mode": "chatgpt", - "source": "device-code", - } - - # ==================== MiniMax Portal OAuth ==================== -_MINIMAX_OAUTH_ERROR_BODY_LIMIT = 16 * 1024 - - -def _minimax_response_error_text( - response: httpx.Response, - *, - limit: int = _MINIMAX_OAUTH_ERROR_BODY_LIMIT, -) -> str: - """Return a bounded error body from a streamed MiniMax OAuth response.""" - limit = max(0, int(limit)) - chunks: list[bytes] = [] - total = 0 - truncated = False - try: - if getattr(response, "is_stream_consumed", False): - text = response.text - return text[:limit] + ("...[truncated]" if len(text) > limit else "") - - for chunk in response.iter_bytes(): - if not chunk: - continue - remaining = limit + 1 - total - if remaining <= 0: - truncated = True - break - if len(chunk) > remaining: - chunks.append(chunk[:remaining]) - total += remaining - truncated = True - break - chunks.append(chunk) - total += len(chunk) - raw = b"".join(chunks) - if len(raw) > limit: - raw = raw[:limit] - truncated = True - encoding = response.encoding or "utf-8" - text = raw.decode(encoding, errors="replace") - return text + ("...[truncated]" if truncated else "") - finally: - response.close() - - -def _minimax_post_form( - client: httpx.Client, - url: str, - *, - data: Dict[str, Any], - headers: Dict[str, str], -) -> httpx.Response: - """POST a MiniMax OAuth form without eagerly reading error bodies.""" - request = client.build_request( - "POST", - url, - data=data, - headers=headers, - ) - response = client.send(request, stream=True) - if response.status_code == 200: - response.read() - return response - -def _minimax_pkce_pair() -> tuple: - """Generate (code_verifier, code_challenge_S256, state) for MiniMax OAuth.""" - import secrets - verifier = secrets.token_urlsafe(64)[:96] - challenge = base64.urlsafe_b64encode( - hashlib.sha256(verifier.encode()).digest() - ).decode().rstrip("=") - state = secrets.token_urlsafe(16) - return verifier, challenge, state - - -def _minimax_request_user_code( - client: httpx.Client, *, portal_base_url: str, client_id: str, - code_challenge: str, state: str, -) -> Dict[str, Any]: - response = _minimax_post_form( - client, - f"{portal_base_url}/oauth/code", - data={ - "response_type": "code", - "client_id": client_id, - "scope": MINIMAX_OAUTH_SCOPE, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - "state": state, - }, - headers={**_FORM_JSON_HEADERS, "x-request-id": str(uuid.uuid4())}, - ) - if response.status_code != 200: - body = _minimax_response_error_text(response) - raise _minimax_err( - f"MiniMax OAuth authorization failed: {body or response.reason_phrase}", - "authorization_failed", - ) - payload = response.json() - for field in ("user_code", "verification_uri", "expired_in"): - if field not in payload: - raise _minimax_err( - f"MiniMax OAuth response missing field: {field}", - "authorization_incomplete", - ) - if payload.get("state") != state: - raise _minimax_err("MiniMax OAuth state mismatch (possible CSRF).", "state_mismatch") - return payload - - -def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> bool: - """True if ``expired_in`` is plausibly a unix-ms absolute time (vs TTL seconds).""" - return int(expired_in) > (now_ms // 2) - - -def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float: - """Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds).""" - raw = int(expired_in) - now_ms = int(now.timestamp() * 1000) - if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms): - return raw / 1000.0 - return now.timestamp() + max(1, raw) - - -def _minimax_expiry_fields(expired_in: Any) -> Dict[str, Any]: - """``obtained_at`` / ``expires_at`` / ``expires_in`` derived from a MiniMax ``expired_in``.""" - now = datetime.now(timezone.utc) - expires_at_unix = _minimax_resolve_token_expiry_unix(int(expired_in), now=now) - return { - "obtained_at": now.isoformat(), - "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(), - "expires_in": max(0, int(expires_at_unix - now.timestamp())), - } - - -def _minimax_poll_token( - client: httpx.Client, *, portal_base_url: str, client_id: str, - user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int], -) -> Dict[str, Any]: - # OpenClaw treats expired_in as a unix-ms timestamp (Date.now() < expireTimeMs). - # Defensive parsing: if it's small enough to be a duration, treat as seconds. - deadline = _minimax_resolve_token_expiry_unix(expired_in, now=datetime.now(timezone.utc)) - interval = max(2.0, (interval_ms or 2000) / 1000.0) - - while time.time() < deadline: - response = _minimax_post_form( - client, - f"{portal_base_url}/oauth/token", - data={ - "grant_type": MINIMAX_OAUTH_GRANT_TYPE, - "client_id": client_id, - "user_code": user_code, - "code_verifier": code_verifier, - }, - headers=_FORM_JSON_HEADERS, - ) - error_text = "" - if response.status_code != 200: - error_text = _minimax_response_error_text(response) - try: - payload = json.loads(error_text) if error_text else {} - except Exception: - payload = {} - msg = (payload.get("base_resp", {}) or {}).get("status_msg") or error_text - raise _minimax_err(f"MiniMax OAuth error: {msg or 'unknown'}", "token_exchange_failed") - try: - payload = response.json() if response.text else {} - except Exception: - payload = {} - - status = payload.get("status") - if status == "error": - raise _minimax_err( - "MiniMax OAuth reported an error. Please try again later.", - "authorization_denied", - ) - if status == "success": - if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")): - raise _minimax_err( - "MiniMax OAuth success payload missing required token fields.", - "token_incomplete", - ) - return payload - # "pending" or any other status -> keep polling - time.sleep(interval) - - raise _minimax_err("MiniMax OAuth timed out before authorization completed.", "timeout") - - -def _minimax_save_auth_state(auth_state: Dict[str, Any]) -> None: - """Persist MiniMax OAuth state to Hermes auth store (~/.hermes/auth.json).""" - _save_active_provider_state("minimax-oauth", auth_state) - - -def _minimax_oauth_login( - *, region: str = "global", open_browser: bool = True, - timeout_seconds: float = 15.0, -) -> Dict[str, Any]: - """Run MiniMax OAuth flow, persist tokens, return auth state dict.""" - pconfig = PROVIDER_REGISTRY["minimax-oauth"] - if region == "cn": - portal_base_url = pconfig.extra["cn_portal_base_url"] - inference_base_url = pconfig.extra["cn_inference_base_url"] - else: - portal_base_url = pconfig.portal_base_url - inference_base_url = pconfig.inference_base_url - - verifier, challenge, state = _minimax_pkce_pair() - - if _is_remote_session(): - open_browser = False - - print(f"Starting Hermes login via MiniMax ({region}) OAuth...") - print(f"Portal: {portal_base_url}") - - with httpx.Client(timeout=httpx.Timeout(timeout_seconds), - headers={"Accept": "application/json"}, - follow_redirects=True) as client: - code_data = _minimax_request_user_code( - client, portal_base_url=portal_base_url, - client_id=pconfig.client_id, - code_challenge=challenge, state=state, - ) - verification_url = str(code_data["verification_uri"]) - user_code = str(code_data["user_code"]) - - _print_device_code_instructions( - verification_url, - user_code, - open_browser=open_browser and _can_open_graphical_browser(), - ) - - interval_raw = code_data.get("interval") - interval_ms = int(interval_raw) if interval_raw is not None else None - print("Waiting for approval...") - - token_data = _minimax_poll_token( - client, portal_base_url=portal_base_url, - client_id=pconfig.client_id, - user_code=user_code, code_verifier=verifier, - expired_in=int(code_data["expired_in"]), - interval_ms=interval_ms, - ) - - auth_state = { - "provider": "minimax-oauth", - "region": region, - "portal_base_url": portal_base_url, - "inference_base_url": inference_base_url, - "client_id": pconfig.client_id, - "scope": MINIMAX_OAUTH_SCOPE, - "token_type": token_data.get("token_type", "Bearer"), - "access_token": token_data["access_token"], - "refresh_token": token_data["refresh_token"], - "resource_url": token_data.get("resource_url"), - **_minimax_expiry_fields(token_data["expired_in"]), - } - - _minimax_save_auth_state(auth_state) - print("\u2713 MiniMax OAuth login successful.") - if msg := token_data.get("notification_message"): - print(f"Note from MiniMax: {msg}") - return auth_state - - -def _refresh_minimax_oauth_state( - state: Dict[str, Any], *, timeout_seconds: float = 15.0, - force: bool = False, -) -> Dict[str, Any]: - """Refresh MiniMax OAuth access token if close to expiry (or forced).""" - if not state.get("refresh_token"): - raise _minimax_err( - "MiniMax OAuth state has no refresh_token; please re-login.", - "no_refresh_token", relogin=True, - ) - try: - expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp() - except Exception: - expires_at = 0.0 - now = time.time() - if not force and (expires_at - now) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS: - return state - - portal_base_url = state["portal_base_url"] - with httpx.Client(timeout=httpx.Timeout(timeout_seconds), - follow_redirects=True) as client: - response = _minimax_post_form( - client, - f"{portal_base_url}/oauth/token", - data={ - "grant_type": "refresh_token", - "client_id": state["client_id"], - "refresh_token": state["refresh_token"], - }, - headers=_FORM_JSON_HEADERS, - ) - # The non-200 branch reads a STREAMED body, so it must run while - # the client is still open — iter_bytes() after the client context - # closes raises (StreamClosed). The 200 path was already read by - # _minimax_post_form, so response.json() below is safe outside. - if response.status_code != 200: - body = _minimax_response_error_text(response) - body_lower = body.lower() - relogin = any(m in body_lower for m in - ("invalid_grant", "refresh_token_reused", "invalid_refresh_token")) - raise _minimax_err( - f"MiniMax OAuth refresh failed: {body or response.reason_phrase}", - "refresh_failed", relogin=relogin, - ) - payload = response.json() - if payload.get("status") != "success": - raise _minimax_err( - "MiniMax OAuth refresh did not return success.", - "refresh_failed", relogin=True, - ) - new_state = dict(state) - new_state.update({ - "access_token": payload["access_token"], - "refresh_token": payload.get("refresh_token", state["refresh_token"]), - **_minimax_expiry_fields(payload["expired_in"]), - }) - _minimax_save_auth_state(new_state) - return new_state - - -def _minimax_oauth_quarantine_on_terminal_refresh(state: Dict[str, Any], exc: AuthError) -> None: - """Wipe dead tokens from auth.json after a terminal refresh failure. - - Shared by the eager-resolve path and the lazy per-request token provider. Mirrors the - Nous / xAI / Codex quarantine pattern so subsequent calls fail fast without a network retry. - """ - if not (exc.relogin_required and state.get("refresh_token")): - return - _quarantine_flat_oauth_state(state, "minimax-oauth", exc) - try: - _minimax_save_auth_state(state) - except Exception as _save_exc: - logger.debug("MiniMax OAuth: failed to persist quarantined state: %s", _save_exc) - - -def _minimax_fresh_state() -> Dict[str, Any]: - """Load the MiniMax OAuth state and refresh it if near expiry; quarantine on terminal failure.""" - state = get_provider_auth_state("minimax-oauth") - if not state or not state.get("access_token"): - raise _minimax_err( - "Not logged into MiniMax OAuth. Run `hermes model` and select " - "MiniMax (OAuth).", - "not_logged_in", relogin=True, - ) - try: - return _refresh_minimax_oauth_state(state) - except AuthError as exc: - _minimax_oauth_quarantine_on_terminal_refresh(state, exc) - raise - - -def build_minimax_oauth_token_provider() -> Callable[[], str]: - """Return a zero-arg callable that yields a fresh MiniMax access token. - - The Anthropic SDK caches ``api_key`` as a static string at construction time, so a session that - resolves credentials once at startup will keep sending the same bearer until MiniMax's server - returns 401 — typically ~15 minutes in, because MiniMax issues short-lived access tokens. - """ - def _provide() -> str: - state = _minimax_fresh_state() - token = state.get("access_token") - if not token: - raise _minimax_err( - "MiniMax OAuth state has no access_token after refresh.", - "no_access_token", relogin=True, - ) - return token - - return _provide - - -def resolve_minimax_oauth_runtime_credentials( - *, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, - as_token_provider: bool = False, -) -> Dict[str, Any]: - """Return {provider, api_key, base_url, source} for minimax-oauth. - - The default (string ``api_key``) preserves the historical contract for diagnostic call sites - like ``hermes status`` that just want to know whether a valid token exists right now. - """ - state = _minimax_fresh_state() - if as_token_provider: - api_key: Any = build_minimax_oauth_token_provider() - else: - api_key = state["access_token"] - return { - "provider": "minimax-oauth", - "api_key": api_key, - "base_url": state["inference_base_url"].rstrip("/"), - "source": "oauth", - } - def get_minimax_oauth_auth_status() -> Dict[str, Any]: """Return auth status dict for MiniMax OAuth provider.""" @@ -8896,20 +6225,6 @@ def get_minimax_oauth_auth_status() -> Dict[str, Any]: } -def _login_minimax_oauth(args, pconfig: ProviderConfig) -> None: - """CLI entry for MiniMax OAuth login.""" - region = getattr(args, "region", None) or "global" - open_browser = not getattr(args, "no_browser", False) - timeout = getattr(args, "timeout", None) or 15.0 - try: - _minimax_oauth_login( - region=region, open_browser=open_browser, timeout_seconds=timeout, - ) - except AuthError as exc: - print(format_auth_error(exc)) - raise SystemExit(1) - - def _nous_device_code_login( *, portal_base_url: Optional[str] = None, diff --git a/hermes_cli/auth_codex.py b/hermes_cli/auth_codex.py new file mode 100644 index 0000000000..f59e86e41b --- /dev/null +++ b/hermes_cli/auth_codex.py @@ -0,0 +1,1084 @@ +"""OpenAI Codex OAuth: token store, refresh, quota probe, device-code login. + +Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so +``hermes_cli.auth.`` keeps resolving (and monkeypatching) as before. Origin-internal +helpers are imported lazily inside each function (no import cycle; patches on +``hermes_cli.auth.`` still intercept). +""" + +from __future__ import annotations + +import logging +import hashlib +import json +import os +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from hermes_cli.auth_constants import ( + AUTH_LOCK_TIMEOUT_SECONDS, + AuthError, + CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + CODEX_OAUTH_CLIENT_ID, + CODEX_OAUTH_TOKEN_URL, + CODEX_OAUTH_USER_AGENT, + CODEX_RATE_LIMITED_CODE, + DEFAULT_CODEX_BASE_URL, + _codex_err, + httpx, +) +from utils import env_float + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle + from hermes_cli.auth import ProviderConfig + +# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth"). +logger = logging.getLogger("hermes_cli.auth") + + +def _codex_base_url() -> str: + return os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL + + +def _codex_runtime_result(api_key: str, *, source: str, last_refresh: Optional[str]) -> Dict[str, Any]: + return { + "provider": "openai-codex", + "base_url": _codex_base_url(), + "api_key": api_key, + "source": source, + "last_refresh": last_refresh, + "auth_mode": "chatgpt", + } + + +def _load_auth_store_maybe_locked(lock: bool) -> Dict[str, Any]: + """Load the auth store, taking the cross-process lock unless the caller already holds it.""" + from hermes_cli.auth import _auth_store_lock, _load_auth_store + if lock: + with _auth_store_lock(): + return _load_auth_store() + return _load_auth_store() + + +def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: + """Read Codex OAuth tokens from Hermes auth store (~/.hermes/auth.json).""" + from hermes_cli.auth import _load_provider_state, _nonempty_str + auth_store = _load_auth_store_maybe_locked(_lock) + state = _load_provider_state(auth_store, "openai-codex") + if not state: + raise _codex_err( + "No Codex credentials stored. Run `hermes auth` to authenticate.", + "codex_auth_missing", relogin=True, + ) + tokens = state.get("tokens") + if not isinstance(tokens, dict): + raise _codex_err( + "Codex auth state is missing tokens. Run `hermes auth` to re-authenticate.", + "codex_auth_invalid_shape", relogin=True, + ) + access_token = tokens.get("access_token") + refresh_token = tokens.get("refresh_token") + if not _nonempty_str(access_token): + raise _codex_err( + "Codex auth is missing access_token. Run `hermes auth` to re-authenticate.", + "codex_auth_missing_access_token", relogin=True, + ) + if not _nonempty_str(refresh_token): + raise _codex_err( + "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.", + "codex_auth_missing_refresh_token", relogin=True, + ) + return { + "tokens": tokens, + "last_refresh": state.get("last_refresh"), + } + + +def _sync_codex_pool_entries( + auth_store: Dict[str, Any], + tokens: Dict[str, str], + last_refresh: Optional[str], + previous_singleton_tokens: Optional[Dict[str, str]] = None, +) -> None: + """Mirror a fresh Codex re-auth into the credential_pool OAuth entries. + + * ``device_code`` — the singleton-seeded entry written by the device-code OAuth flow when the + user logged in via ``hermes setup`` / the model picker. Always synced with the fresh tokens. * + ``manual:device_code`` — entries created by ``hermes auth add openai-codex`` that use the same + device-code OAuth mechanism. + + * ``manual:api_key`` and any other non-device-code manual sources — those are independent + credentials (an explicit API key, a different ChatGPT account, etc.) and must not be overwritten + by a single re-auth. + """ + from hermes_cli.auth import _clear_pool_entry_status + access_token = tokens.get("access_token") + if not access_token: + return + refresh_token = tokens.get("refresh_token") + entries = _pool_entries(auth_store, "openai-codex") + if entries is None: + return + # Previous singleton access_token (before this re-auth overwrote it) — + # used to distinguish legacy singleton-aliases from independent accounts. + # When None or empty, no manual entry can be treated as an alias (which + # is the right default for first-ever-save or a freshly initialized + # auth.json). + prev_at = None + if isinstance(previous_singleton_tokens, dict): + prev_at = previous_singleton_tokens.get("access_token") or None + for entry in entries: + if not isinstance(entry, dict): + continue + source = entry.get("source") + if source == "device_code": + # Singleton-seeded mirror — always refresh. + refresh_this_entry = True + elif source == "manual:device_code": + # Refresh only if this entry's existing access_token matches the + # previous singleton access_token (i.e. it is a true alias of the + # singleton from the #33000 workaround era). An entry with its + # own distinct token material is an independent account and must + # be left alone (#39236). + refresh_this_entry = bool( + prev_at and entry.get("access_token") == prev_at + ) + else: + # ``manual:api_key`` and any future non-device-code sources. + refresh_this_entry = False + if not refresh_this_entry: + continue + entry["access_token"] = access_token + if refresh_token: + entry["refresh_token"] = refresh_token + if last_refresh: + entry["last_refresh"] = last_refresh + _clear_pool_entry_status(entry) + + +def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None: + """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).""" + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, _save_provider_state, _utc_now_z + if last_refresh is None: + last_refresh = _utc_now_z() + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "openai-codex") or {} + # Capture the previous singleton tokens BEFORE overwriting them. The + # pool-sync step uses this to distinguish legacy singleton-aliases + # (which should be refreshed) from independent accounts that + # ``hermes auth add openai-codex`` created (which must not be + # overwritten — see #39236). + previous_singleton_tokens = state.get("tokens") if isinstance(state.get("tokens"), dict) else None + state["tokens"] = tokens + state["last_refresh"] = last_refresh + state["auth_mode"] = "chatgpt" + if label and str(label).strip(): + state["label"] = str(label).strip() + _save_provider_state(auth_store, "openai-codex", state) + _sync_codex_pool_entries( + auth_store, + tokens, + last_refresh, + previous_singleton_tokens=previous_singleton_tokens, + ) + _save_auth_store(auth_store) + + +def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]: + """Adopt a valid Codex CLI token pair into Hermes auth, if available.""" + from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens + imported = _import_codex_cli_tokens() + # Require BOTH tokens before adopting: persisting a payload without a + # usable refresh_token would only break the next refresh cycle. + if not ( + imported + and str(imported.get("access_token", "") or "").strip() + and str(imported.get("refresh_token", "") or "").strip() + ): + return None + logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason) + _save_codex_tokens(imported) + return dict(imported) + + +def _refresh_payload_access_token( + response: "httpx.Response", + *, + provider: str, + invalid_json: Tuple[str, str], + invalid_response: Optional[Tuple[str, str]], + missing_access: Tuple[str, str], + relogin_required: bool = True, + invalid_json_relogin: Optional[bool] = None, + strict_str: bool = True, +) -> Tuple[Dict[str, Any], str]: + """Parse a 200 token-refresh response; return ``(payload, stripped access_token)``. + + Each ``(message, code)`` pair keeps the provider's historical wording; ``{exc}`` in + *invalid_json*'s message is formatted with the JSON error. *strict_str* rejects non-string + access tokens; otherwise they are ``str()``-coerced. + """ + try: + payload = response.json() + except Exception as exc: + raise AuthError( + invalid_json[0].format(exc=exc), + provider=provider, + code=invalid_json[1], + relogin_required=( + relogin_required if invalid_json_relogin is None else invalid_json_relogin + ), + ) from exc + if not isinstance(payload, dict): + if invalid_response is None: + payload = {} + else: + raise AuthError( + invalid_response[0], + provider=provider, + code=invalid_response[1], + relogin_required=relogin_required, + ) + access = payload.get("access_token") + if strict_str: + access = access.strip() if isinstance(access, str) else "" + else: + access = str(access or "").strip() + if not access: + raise AuthError( + missing_access[0], + provider=provider, + code=missing_access[1], + relogin_required=relogin_required, + ) + return payload, access + + +def _codex_http_client(**kwargs: Any) -> "httpx.Client": + """Build an ``httpx.Client`` for Codex OAuth/probe endpoints with racing. + + Same broken-IPv6 failure mode as the chat transport (#13834): a host that advertises AAAA + records but blackholes IPv6 makes each serial connect attempt eat the full connect timeout + before IPv4 is tried, so token refresh / device login / usage probes time out where the official + Codex CLI (which races families per RFC 8305) works. + + Best-effort: if the racing backend can't be installed (unexpected httpx/httpcore internals, + mocked client in tests), the client still works with the default serial connect behavior. + """ + client = httpx.Client(**kwargs) + try: + from agent.process_bootstrap import enable_happy_eyeballs_on_client + + enable_happy_eyeballs_on_client(client) + except Exception: + pass + return client + + +def _codex_quota_exhausted_error(retry_after: Optional[int]) -> AuthError: + if retry_after is not None: + message = ( + f"Codex provider quota exhausted (429); retry after {retry_after}s. " + "Credentials are still valid." + ) + else: + message = ( + "Codex provider quota exhausted (429). Credentials are still valid; " + "retry after the usage limit resets." + ) + return _codex_err(message, CODEX_RATE_LIMITED_CODE, relogin=False) + + +def _codex_refresh_failure_error(response: "httpx.Response") -> AuthError: + """Decode a non-200 Codex token-refresh response into a shaped AuthError.""" + from hermes_cli.auth import _nonempty_str + code = "codex_refresh_failed" + message = f"Codex token refresh failed with status {response.status_code}." + relogin_required = False + try: + err = response.json() + if isinstance(err, dict): + err_obj = err.get("error") + # OpenAI shape: {"error": {"code": "...", "message": "...", "type": "..."}} + if isinstance(err_obj, dict): + nested_code = err_obj.get("code") or err_obj.get("type") + if _nonempty_str(nested_code): + code = nested_code.strip() + nested_msg = err_obj.get("message") + if _nonempty_str(nested_msg): + message = f"Codex token refresh failed: {nested_msg.strip()}" + # OAuth spec shape: {"error": "code_str", "error_description": "..."} + elif _nonempty_str(err_obj): + code = err_obj.strip() + err_desc = err.get("error_description") or err.get("message") + if _nonempty_str(err_desc): + message = f"Codex token refresh failed: {err_desc.strip()}" + except Exception: + pass + if code in {"invalid_grant", "invalid_token", "invalid_request"}: + relogin_required = True + if code == "refresh_token_reused": + message = ( + "Codex refresh token was already consumed by another client " + "(e.g. Codex CLI or VS Code extension). " + "Run `codex` in your terminal to generate fresh tokens, " + "then run `hermes auth` to re-authenticate." + ) + relogin_required = True + # A 401/403 from the token endpoint always means the refresh token + # is invalid/expired — force relogin even if the body error code + # wasn't one of the known strings above. + if response.status_code in {401, 403} and not relogin_required: + relogin_required = True + return _codex_err(message, code, relogin=relogin_required) + + +def refresh_codex_oauth_pure( + access_token: str, + refresh_token: str, + *, + timeout_seconds: float = 20.0, +) -> Dict[str, Any]: + """Refresh Codex OAuth tokens without mutating Hermes auth state.""" + from hermes_cli.auth import _nonempty_str, _parse_retry_after_seconds, _utc_now_z + del access_token # Access token is only used by callers to decide whether to refresh. + if not _nonempty_str(refresh_token): + raise _codex_err( + "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.", + "codex_auth_missing_refresh_token", relogin=True, + ) + + timeout = httpx.Timeout(max(5.0, float(timeout_seconds))) + with _codex_http_client( + timeout=timeout, + headers={ + "Accept": "application/json", + "User-Agent": CODEX_OAUTH_USER_AGENT, + }, + ) as client: + response = client.post( + CODEX_OAUTH_TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CODEX_OAUTH_CLIENT_ID, + }, + ) + + if response.status_code == 429: + # Upstream rate-limit / usage-quota exhaustion on the token endpoint. + # The stored refresh token is still valid here — re-authenticating + # cannot lift a quota cap. Classify distinctly from auth failures so + # callers surface a "retry later" notice instead of a misleading + # "run hermes auth" prompt (see issue #32790). + raise _codex_quota_exhausted_error( + _parse_retry_after_seconds(getattr(response, "headers", None)) + ) + + if response.status_code != 200: + raise _codex_refresh_failure_error(response) + + refresh_payload, refreshed_access = _refresh_payload_access_token( + response, + provider="openai-codex", + invalid_json=("Codex token refresh returned invalid JSON.", "codex_refresh_invalid_json"), + invalid_response=None, + missing_access=( + "Codex token refresh response was missing access_token.", + "codex_refresh_missing_access_token", + ), + ) + + updated = { + "access_token": refreshed_access, + "refresh_token": refresh_token.strip(), + "last_refresh": _utc_now_z(), + } + next_refresh = refresh_payload.get("refresh_token") + if _nonempty_str(next_refresh): + updated["refresh_token"] = next_refresh.strip() + return updated + + +def _refresh_codex_auth_tokens( + tokens: Dict[str, str], + timeout_seconds: float, +) -> Dict[str, str]: + """Refresh Codex access token using the refresh token.""" + from hermes_cli.auth import _save_codex_tokens, refresh_codex_oauth_pure + try: + refreshed = refresh_codex_oauth_pure( + str(tokens.get("access_token", "") or ""), + str(tokens.get("refresh_token", "") or ""), + timeout_seconds=timeout_seconds, + ) + except AuthError as exc: + # Self-heal cross-store refresh_token rotation. Hermes keeps its OWN + # Codex OAuth token (per profile + top-level), separate from the Codex + # CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when + # the Codex CLI (or another Hermes process) rotates the shared token, + # this frozen copy's refresh_token goes stale and the refresh fails with + # a relogin-required error (invalid_grant / refresh_token_reused / 401). + # Before surfacing that as a hard 401 to the turn, adopt the canonical + # fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so + # idle profiles / desktop sessions recover automatically instead of + # 401'ing until a manual re-auth. Transient failures (e.g. 429 quota) + # keep relogin_required=False — the stored token is still valid there, so + # we never self-heal those and re-raise unchanged. + if not getattr(exc, "relogin_required", False): + raise + imported = _recover_codex_tokens_from_cli( + f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}" + ) + if not imported: + raise + return imported + + updated_tokens = dict(tokens) + updated_tokens["access_token"] = refreshed["access_token"] + updated_tokens["refresh_token"] = refreshed["refresh_token"] + + _save_codex_tokens(updated_tokens) + return updated_tokens + + +def _import_codex_cli_tokens() -> Optional[Dict[str, str]]: + """Try to read tokens from ~/.codex/auth.json (Codex CLI shared file). + + Returns tokens dict if valid and not expired, None otherwise. Does NOT write to the shared file. + """ + from hermes_cli.auth import _codex_access_token_is_expiring + codex_home = os.getenv("CODEX_HOME", "").strip() + if not codex_home: + codex_home = str(Path.home() / ".codex") + auth_path = Path(codex_home).expanduser() / "auth.json" + if not auth_path.is_file(): + return None + try: + payload = json.loads(auth_path.read_text(encoding="utf-8-sig")) + tokens = payload.get("tokens") + if not isinstance(tokens, dict): + return None + access_token = tokens.get("access_token") + refresh_token = tokens.get("refresh_token") + if not access_token or not refresh_token: + return None + # Reject expired tokens — importing stale tokens from ~/.codex/ + # that can't be refreshed leaves the user stuck with "Login successful!" + # but no working credentials. + if _codex_access_token_is_expiring(access_token, 0): + logger.debug( + "Codex CLI tokens at %s are expired — skipping import.", auth_path, + ) + return None + return dict(tokens) + except Exception: + return None + + +def resolve_codex_runtime_credentials( + *, + force_refresh: bool = False, + refresh_if_expiring: bool = True, + refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, +) -> Dict[str, Any]: + """Resolve runtime credentials from Hermes's own Codex token store. + + Falls back to the credential pool when the singleton (``providers.openai-codex.tokens``) has no + usable access_token but the pool (``credential_pool.openai-codex``) does. + """ + from hermes_cli.auth import _auth_store_lock, _codex_access_token_is_expiring, _probe_codex_quota_restored, _read_codex_tokens + read_error: Optional[AuthError] = None + try: + data = _read_codex_tokens() + except AuthError as exc: + read_error = exc + if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in { + "codex_auth_missing_access_token", + "codex_auth_missing_refresh_token", + "codex_auth_invalid_shape", + }: + imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error")) + if imported: + data = {"tokens": imported, "last_refresh": imported.get("last_refresh")} + else: + data = None + else: + data = None + + if data is None: + pool_token = _pool_codex_access_token() + if pool_token: + return _codex_runtime_result(pool_token, source="credential_pool", last_refresh=None) + pool_rate_limit = _codex_pool_rate_limit_status() + if pool_rate_limit: + # Before surfacing the persisted cooldown, ask the Codex usage + # endpoint whether the quota actually reset early (banked reset + # redeemed, plan upgraded, window reset upstream). The persisted + # ``last_error_reset_at`` can be days in the future while the + # account is already usable again — see issue #43747. + stale_token = str(pool_rate_limit.get("access_token") or "").strip() + if stale_token and _probe_codex_quota_restored( + stale_token, + base_url=pool_rate_limit.get("base_url"), + ): + logger.info( + "Codex quota restored upstream — clearing stale pool cooldown(s)." + ) + clear_codex_pool_quota_cooldowns() + pool_token = _pool_codex_access_token() + if pool_token: + return _codex_runtime_result(pool_token, source="credential_pool", last_refresh=None) + reset_at = pool_rate_limit.get("reset_at") + remaining = ( + int(reset_at - time.time()) + if isinstance(reset_at, (int, float)) and reset_at > time.time() + else None + ) + raise _codex_quota_exhausted_error(remaining) + if read_error is not None: + raise read_error + raise _codex_err( + "No Codex credentials stored. Run `hermes auth` to authenticate.", + "codex_auth_missing", relogin=True, + ) + + tokens = dict(data["tokens"]) + access_token = str(tokens.get("access_token", "") or "").strip() + refresh_timeout_seconds = env_float("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", 20) + + should_refresh = bool(force_refresh) + if (not should_refresh) and refresh_if_expiring: + should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds) + if should_refresh: + # Re-read under lock to avoid racing with other Hermes processes + with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)): + data = _read_codex_tokens(_lock=False) + tokens = dict(data["tokens"]) + access_token = str(tokens.get("access_token", "") or "").strip() + + should_refresh = bool(force_refresh) + if (not should_refresh) and refresh_if_expiring: + should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds) + + if should_refresh: + tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds) + access_token = str(tokens.get("access_token", "") or "").strip() + + return _codex_runtime_result( + access_token, source="hermes-auth-store", last_refresh=data.get("last_refresh"), + ) + + +def _is_codex_rate_limit_shaped( + code: Any, + reason: Any, + message: Any, +) -> bool: + """True when persisted pool-entry error metadata describes a 429/quota stop.""" + reason_l = str(reason or "").lower() + message_l = str(message or "").lower() + return ( + code == 429 + or "rate_limit" in reason_l + or "usage_limit" in reason_l + or "quota" in reason_l + or "rate limit" in message_l + or "usage limit" in message_l + or "quota" in message_l + ) + + +# Throttle for the live Codex quota probe below. The probe runs on the hot +# credential-selection path while the pool is exhausted, so without a floor a +# busy gateway would hammer the usage endpoint on every model/auxiliary call. +CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS = 300 # 5 minutes + + +_codex_quota_probe_cache: Dict[str, Tuple[float, Optional[bool]]] = {} + + +_codex_quota_probe_lock = threading.Lock() + + +def _codex_usage_probe_url(base_url: Optional[str]) -> str: + """Resolve the Codex usage endpoint for a probe. + + Mirrors the Codex CLI's PathStyle split: base URLs containing ``/backend-api`` use the ChatGPT + ``/wham/usage`` path, everything else ``/api/codex/usage``. Kept local so this low-level auth + module does not import the auxiliary account-usage module. + """ + normalized = str(base_url or "").strip().rstrip("/") + if not normalized: + normalized = _codex_base_url() + if normalized.endswith("/codex"): + normalized = normalized[: -len("/codex")] + prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex") + return prefix + "/usage" + + +def _probe_codex_quota_restored( + access_token: Any, + *, + base_url: Optional[str] = None, + min_interval_seconds: float = CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS, +) -> Optional[bool]: + """Ask the Codex usage endpoint whether this account's quota is usable again. + + Probes are throttled per access token (module-local cache) so the hot selection path can fire + this freely. + """ + from hermes_cli.auth import _codex_quota_probe_cache, _decode_jwt_claims, _nonempty_str + token = str(access_token or "").strip() + if not token: + return None + # Real Codex access tokens are JWTs. Refusing to probe non-JWT tokens + # avoids pointless network calls for corrupt/placeholder entries (and + # keeps hermetic test fixtures with dummy tokens offline). + if not _decode_jwt_claims(token): + return None + cache_key = hashlib.sha256(token.encode("utf-8")).hexdigest()[:16] + now = time.monotonic() + with _codex_quota_probe_lock: + cached = _codex_quota_probe_cache.get(cache_key) + if cached is not None and (now - cached[0]) < min_interval_seconds: + return cached[1] + # Reserve the slot immediately so concurrent selectors don't stampede + # the endpoint while this probe is in flight. + _codex_quota_probe_cache[cache_key] = (now, None) + + result: Optional[bool] = None + try: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "User-Agent": "codex-cli", + } + # Best-effort ChatGPT-Account-Id from the JWT (the backend requires it + # for some account shapes; harmless to omit for others). + claims = _decode_jwt_claims(token) + account_id = ( + claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id") + if isinstance(claims.get("https://api.openai.com/auth"), dict) + else None + ) + if _nonempty_str(account_id): + headers["ChatGPT-Account-Id"] = account_id.strip() + with _codex_http_client(timeout=10.0) as client: + response = client.get(_codex_usage_probe_url(base_url), headers=headers) + if response.status_code == 200: + payload = response.json() or {} + rate_limit = payload.get("rate_limit") or {} + worst_used: Optional[float] = None + for key in ("primary_window", "secondary_window"): + used = (rate_limit.get(key) or {}).get("used_percent") + if isinstance(used, (int, float)): + worst_used = max(worst_used or 0.0, float(used)) + if worst_used is not None: + result = worst_used < 100.0 + elif response.status_code == 429: + result = False + except Exception: + logger.debug("Codex quota probe failed", exc_info=True) + result = None + + with _codex_quota_probe_lock: + _codex_quota_probe_cache[cache_key] = (now, result) + return result + + +def clear_codex_pool_quota_cooldowns(access_token: Optional[str] = None) -> int: + """Clear rate-limit cooldowns on persisted openai-codex pool entries. + + Called after the upstream quota is KNOWN to be restored (a successful ``/usage reset`` + redemption, or a positive live probe) so auth.json stops freezing credentials behind a stale + ``last_error_reset_at``. + + When *access_token* is given, only the matching entry is cleared; otherwise every rate-limited + entry clears (a redeemed banked reset restores the whole account, and any entry that is + genuinely still exhausted just re-freezes with fresh metadata on its next 429). + """ + from hermes_cli.auth import _auth_store_lock, _clear_pool_entry_status, _load_auth_store, _save_auth_store + cleared = 0 + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + entries = _pool_entries(auth_store, "openai-codex") + if entries is None: + return 0 + for entry in entries: + if not isinstance(entry, dict): + continue + if entry.get("last_status") != "exhausted": + continue + if access_token and str(entry.get("access_token") or "") != access_token: + continue + if not _is_codex_rate_limit_shaped( + entry.get("last_error_code"), + entry.get("last_error_reason"), + entry.get("last_error_message"), + ): + continue + _clear_pool_entry_status(entry) + cleared += 1 + if cleared: + _save_auth_store(auth_store) + except Exception: + logger.debug("Failed to clear Codex pool quota cooldowns", exc_info=True) + return cleared + + +def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]: + """Return metadata for a pool-only Codex credential in quota cooldown.""" + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _nonempty_str + def _parse_reset_at(value: Any) -> Optional[float]: + if value is None or value == "": + return None + if isinstance(value, (int, float)): + numeric = float(value) + if numeric <= 0: + return None + return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric + if isinstance(value, str): + raw = value.strip() + if not raw: + return None + try: + numeric = float(raw) + except ValueError: + numeric = None + if numeric is not None: + return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + return None + + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + entries = _pool_entries(auth_store, "openai-codex") + if entries is None: + return None + now = time.time() + for entry in entries: + if not isinstance(entry, dict): + continue + token = entry.get("access_token") + if not _nonempty_str(token): + continue + if entry.get("last_status") != "exhausted": + continue + if not _is_codex_rate_limit_shaped( + entry.get("last_error_code"), + entry.get("last_error_reason"), + entry.get("last_error_message"), + ): + continue + reset_at = _parse_reset_at(entry.get("last_error_reset_at")) + if reset_at is not None and reset_at <= now: + continue + return { + "label": entry.get("label"), + "last_refresh": entry.get("last_refresh"), + "reset_at": reset_at, + "reason": entry.get("last_error_reason"), + "message": entry.get("last_error_message"), + "access_token": token.strip(), + "base_url": entry.get("base_url"), + } + except Exception: + logger.debug("Codex pool rate-limit lookup failed", exc_info=True) + return None + + +def _pool_entries(auth_store: Dict[str, Any], provider_id: str) -> Optional[List[Any]]: + """``auth_store["credential_pool"][provider_id]`` when it is a list, else None.""" + pool = auth_store.get("credential_pool") + entries = pool.get(provider_id) if isinstance(pool, dict) else None + return entries if isinstance(entries, list) else None + + +def _pool_codex_access_token() -> str: + """Return the most-recent usable access_token from the openai-codex pool. + + Used as a fallback by ``resolve_codex_runtime_credentials`` when the singleton has no creds. + Reads ``credential_pool.openai-codex`` entries directly from auth.json and picks the first non- + empty access_token, preferring entries that are not currently in an exhaustion cooldown. + """ + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _nonempty_str + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + entries = _pool_entries(auth_store, "openai-codex") + if entries is None: + return "" + + def _entry_usable(entry: Dict[str, Any]) -> bool: + if not isinstance(entry, dict): + return False + token = entry.get("access_token") + if not _nonempty_str(token): + return False + # Skip entries currently in an exhaustion cooldown window. + reset_at = entry.get("last_error_reset_at") + return not (isinstance(reset_at, (int, float)) and reset_at > time.time()) + + for entry in entries: + if _entry_usable(entry): + return str(entry.get("access_token", "")).strip() + except Exception: + logger.debug("Codex pool fallback lookup failed", exc_info=True) + return "" + + +def _login_openai_codex( + args, + pconfig: ProviderConfig, + *, + force_new_login: bool = False, +) -> None: + """OpenAI Codex login via device code flow. Tokens stored in ~/.hermes/auth.json.""" + from hermes_cli.auth import _codex_access_token_is_expiring, _codex_device_code_login, _import_codex_cli_tokens, _offer_existing_oauth_credentials, _print_login_success, _prompt_yes_no, _save_codex_tokens, _update_config_for_provider, resolve_codex_runtime_credentials + + del args, pconfig # kept for parity with other provider login helpers + + # Check for existing Hermes-owned credentials + if not force_new_login and _offer_existing_oauth_credentials( + "openai-codex", + resolve=resolve_codex_runtime_credentials, + is_expiring=_codex_access_token_is_expiring, + display_name="Codex", + default_base_url=DEFAULT_CODEX_BASE_URL, + expired_notice="Existing Codex credentials are expired. Starting fresh login...", + ): + return + + # Check for existing Codex CLI tokens we can import + if not force_new_login: + cli_tokens = _import_codex_cli_tokens() + if cli_tokens: + print("Found existing Codex CLI credentials at ~/.codex/auth.json") + print("Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.") + if _prompt_yes_no( + "Import these credentials? (a separate login is recommended) [y/N]: ", default="n", + ): + _save_codex_tokens(cli_tokens) + config_path = _update_config_for_provider("openai-codex", _codex_base_url()) + print() + print("Credentials imported. Note: if Codex CLI refreshes its token,") + print("Hermes will keep working independently with its own session.") + print(f" Config updated: {config_path} (model.provider=openai-codex)") + return + + # Run a fresh device code flow — Hermes gets its own OAuth session + print() + print("Signing in to OpenAI Codex...") + print("(Hermes creates its own session — won't affect Codex CLI or VS Code)") + print() + + creds = _codex_device_code_login() + + # Save tokens to Hermes auth store + _save_codex_tokens(creds["tokens"], creds.get("last_refresh")) + config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL)) + _print_login_success("openai-codex", config_path, show_auth_state=True) + + +def _codex_login_rate_limited_error(response: "httpx.Response", *, during: str = "") -> AuthError: + """AuthError for a 429 from OpenAI's device-auth endpoints (a throttle, not a credential fault).""" + from hermes_cli.auth import _parse_retry_after_seconds + retry_after = _parse_retry_after_seconds(getattr(response, "headers", None)) + wait_hint = ( + f" Try again in about {retry_after}s." + if retry_after is not None + else " Wait a minute and run the login again." + ) + return _codex_err( + f"OpenAI is rate-limiting Codex login requests (HTTP 429){during}. " + "This is a temporary throttle on OpenAI's side, not a credential " + f"problem.{wait_hint}", + CODEX_RATE_LIMITED_CODE, + ) + + +def _codex_request_device_code(issuer: str, client_id: str) -> Dict[str, Any]: + """Step 1 of the Codex device flow: request a user code, retrying capped on HTTP 429.""" + from hermes_cli.auth import _parse_retry_after_seconds + # OpenAI's auth endpoint rate-limits this request (HTTP 429) when login is + # attempted too often from the same IP/account — retry with capped backoff + # (honoring ``Retry-After``) before surfacing a clear, actionable message. + resp = None + max_attempts = 4 + for attempt in range(1, max_attempts + 1): + try: + with _codex_http_client(timeout=httpx.Timeout(15.0)) as client: + resp = client.post( + f"{issuer}/api/accounts/deviceauth/usercode", + json={"client_id": client_id}, + headers={"Content-Type": "application/json"}, + ) + except Exception as exc: + raise _codex_err(f"Failed to request device code: {exc}", "device_code_request_failed") + + if resp.status_code != 429: + break + + if attempt < max_attempts: + retry_after = _parse_retry_after_seconds( + getattr(resp, "headers", None) + ) + # Exponential backoff (2s, 4s, 8s) capped, preferring the + # server-provided Retry-After when present. + delay = retry_after if retry_after is not None else 2 ** attempt + delay = max(1, min(int(delay), 60)) + print( + "OpenAI is rate-limiting login requests " + f"(429); retrying in {delay}s..." + ) + time.sleep(delay) + + if resp is not None and resp.status_code == 429: + raise _codex_login_rate_limited_error(resp) + + if resp is None or resp.status_code != 200: + status = resp.status_code if resp is not None else "unknown" + raise _codex_err( + f"Device code request returned status {status}.", + "device_code_request_error", + ) + + device_data = resp.json() + device_data["interval"] = max(3, int(device_data.get("interval", "5"))) + if not device_data.get("user_code", "") or not device_data.get("device_auth_id", ""): + raise _codex_err("Device code response missing required fields.", "device_code_incomplete") + return device_data + + +def _codex_poll_authorization_code( + issuer: str, *, device_auth_id: str, user_code: str, poll_interval: int, +) -> Dict[str, Any]: + """Step 3 of the Codex device flow: poll until sign-in completes (403/404 = still pending).""" + max_wait = 15 * 60 # 15 minutes + start = time.monotonic() + code_resp = None + + try: + with _codex_http_client(timeout=httpx.Timeout(15.0)) as client: + while time.monotonic() - start < max_wait: + time.sleep(poll_interval) + poll_resp = client.post( + f"{issuer}/api/accounts/deviceauth/token", + json={"device_auth_id": device_auth_id, "user_code": user_code}, + headers={"Content-Type": "application/json"}, + ) + + if poll_resp.status_code == 200: + code_resp = poll_resp.json() + break + elif poll_resp.status_code in {403, 404}: + continue # User hasn't completed login yet + else: + raise _codex_err( + f"Device auth polling returned status {poll_resp.status_code}.", + "device_code_poll_error", + ) + except KeyboardInterrupt: + print("\nLogin cancelled.") + raise SystemExit(130) + + if code_resp is None: + raise _codex_err("Login timed out after 15 minutes.", "device_code_timeout") + return code_resp + + +def _codex_exchange_authorization_code( + issuer: str, client_id: str, code_resp: Dict[str, Any], +) -> Dict[str, Any]: + """Step 4 of the Codex device flow: swap the authorization code for tokens.""" + authorization_code = code_resp.get("authorization_code", "") + code_verifier = code_resp.get("code_verifier", "") + redirect_uri = f"{issuer}/deviceauth/callback" + + if not authorization_code or not code_verifier: + raise _codex_err( + "Device auth response missing authorization_code or code_verifier.", + "device_code_incomplete_exchange", + ) + + try: + with _codex_http_client(timeout=httpx.Timeout(15.0)) as client: + token_resp = client.post( + CODEX_OAUTH_TOKEN_URL, + data={ + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "code_verifier": code_verifier, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + except Exception as exc: + raise _codex_err(f"Token exchange failed: {exc}", "token_exchange_failed") + + if token_resp.status_code == 429: + raise _codex_login_rate_limited_error(token_resp, during=" during token exchange") + + if token_resp.status_code != 200: + raise _codex_err( + f"Token exchange returned status {token_resp.status_code}.", + "token_exchange_error", + ) + + tokens = token_resp.json() + if not tokens.get("access_token", ""): + raise _codex_err( + "Token exchange did not return an access_token.", + "token_exchange_no_access_token", + ) + return tokens + + +def _codex_device_code_login() -> Dict[str, Any]: + """Run the OpenAI device code login flow and return credentials dict.""" + from hermes_cli.auth import _utc_now_z + issuer = "https://auth.openai.com" + client_id = CODEX_OAUTH_CLIENT_ID + + device_data = _codex_request_device_code(issuer, client_id) + user_code = device_data["user_code"] + device_auth_id = device_data["device_auth_id"] + poll_interval = device_data["interval"] + + # Step 2: Show user the code + print("To continue, follow these steps:\n") + print(" 1. Open this URL in your browser:") + print(f" \033[94m{issuer}/codex/device\033[0m\n") + print(" 2. Enter this code:") + print(f" \033[94m{user_code}\033[0m\n") + print("Waiting for sign-in... (press Ctrl+C to cancel)") + + code_resp = _codex_poll_authorization_code( + issuer, device_auth_id=device_auth_id, user_code=user_code, poll_interval=poll_interval, + ) + tokens = _codex_exchange_authorization_code(issuer, client_id, code_resp) + + # Return tokens for the caller to persist (no longer writes to ~/.codex/) + return { + "tokens": { + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token", ""), + }, + "base_url": _codex_base_url(), + "last_refresh": _utc_now_z(), + "auth_mode": "chatgpt", + "source": "device-code", + } diff --git a/hermes_cli/auth_minimax.py b/hermes_cli/auth_minimax.py new file mode 100644 index 0000000000..055f8a7efd --- /dev/null +++ b/hermes_cli/auth_minimax.py @@ -0,0 +1,451 @@ +"""MiniMax OAuth (user-code grant) login, refresh and runtime credentials. + +Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so +``hermes_cli.auth.`` keeps resolving (and monkeypatching) as before. Origin-internal +helpers are imported lazily inside each function (no import cycle; patches on +``hermes_cli.auth.`` still intercept). +""" + +from __future__ import annotations + +import logging +import base64 +import hashlib +import json +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Callable, Dict, Optional +from hermes_cli.auth_constants import ( + AuthError, + MINIMAX_OAUTH_GRANT_TYPE, + MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, + MINIMAX_OAUTH_SCOPE, + _FORM_JSON_HEADERS, + _minimax_err, + httpx, +) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle + from hermes_cli.auth import ProviderConfig + +# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth"). +logger = logging.getLogger("hermes_cli.auth") + + +_MINIMAX_OAUTH_ERROR_BODY_LIMIT = 16 * 1024 + + +def _minimax_response_error_text( + response: httpx.Response, + *, + limit: int = _MINIMAX_OAUTH_ERROR_BODY_LIMIT, +) -> str: + """Return a bounded error body from a streamed MiniMax OAuth response.""" + limit = max(0, int(limit)) + chunks: list[bytes] = [] + total = 0 + truncated = False + try: + if getattr(response, "is_stream_consumed", False): + text = response.text + return text[:limit] + ("...[truncated]" if len(text) > limit else "") + + for chunk in response.iter_bytes(): + if not chunk: + continue + remaining = limit + 1 - total + if remaining <= 0: + truncated = True + break + if len(chunk) > remaining: + chunks.append(chunk[:remaining]) + total += remaining + truncated = True + break + chunks.append(chunk) + total += len(chunk) + raw = b"".join(chunks) + if len(raw) > limit: + raw = raw[:limit] + truncated = True + encoding = response.encoding or "utf-8" + text = raw.decode(encoding, errors="replace") + return text + ("...[truncated]" if truncated else "") + finally: + response.close() + + +def _minimax_post_form( + client: httpx.Client, + url: str, + *, + data: Dict[str, Any], + headers: Dict[str, str], +) -> httpx.Response: + """POST a MiniMax OAuth form without eagerly reading error bodies.""" + request = client.build_request( + "POST", + url, + data=data, + headers=headers, + ) + response = client.send(request, stream=True) + if response.status_code == 200: + response.read() + return response + + +def _minimax_pkce_pair() -> tuple: + """Generate (code_verifier, code_challenge_S256, state) for MiniMax OAuth.""" + import secrets + verifier = secrets.token_urlsafe(64)[:96] + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() + ).decode().rstrip("=") + state = secrets.token_urlsafe(16) + return verifier, challenge, state + + +def _minimax_request_user_code( + client: httpx.Client, *, portal_base_url: str, client_id: str, + code_challenge: str, state: str, +) -> Dict[str, Any]: + response = _minimax_post_form( + client, + f"{portal_base_url}/oauth/code", + data={ + "response_type": "code", + "client_id": client_id, + "scope": MINIMAX_OAUTH_SCOPE, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": state, + }, + headers={**_FORM_JSON_HEADERS, "x-request-id": str(uuid.uuid4())}, + ) + if response.status_code != 200: + body = _minimax_response_error_text(response) + raise _minimax_err( + f"MiniMax OAuth authorization failed: {body or response.reason_phrase}", + "authorization_failed", + ) + payload = response.json() + for field in ("user_code", "verification_uri", "expired_in"): + if field not in payload: + raise _minimax_err( + f"MiniMax OAuth response missing field: {field}", + "authorization_incomplete", + ) + if payload.get("state") != state: + raise _minimax_err("MiniMax OAuth state mismatch (possible CSRF).", "state_mismatch") + return payload + + +def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> bool: + """True if ``expired_in`` is plausibly a unix-ms absolute time (vs TTL seconds).""" + return int(expired_in) > (now_ms // 2) + + +def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float: + """Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds).""" + raw = int(expired_in) + now_ms = int(now.timestamp() * 1000) + if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms): + return raw / 1000.0 + return now.timestamp() + max(1, raw) + + +def _minimax_expiry_fields(expired_in: Any) -> Dict[str, Any]: + """``obtained_at`` / ``expires_at`` / ``expires_in`` derived from a MiniMax ``expired_in``.""" + now = datetime.now(timezone.utc) + expires_at_unix = _minimax_resolve_token_expiry_unix(int(expired_in), now=now) + return { + "obtained_at": now.isoformat(), + "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(), + "expires_in": max(0, int(expires_at_unix - now.timestamp())), + } + + +def _minimax_poll_token( + client: httpx.Client, *, portal_base_url: str, client_id: str, + user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int], +) -> Dict[str, Any]: + # OpenClaw treats expired_in as a unix-ms timestamp (Date.now() < expireTimeMs). + # Defensive parsing: if it's small enough to be a duration, treat as seconds. + deadline = _minimax_resolve_token_expiry_unix(expired_in, now=datetime.now(timezone.utc)) + interval = max(2.0, (interval_ms or 2000) / 1000.0) + + while time.time() < deadline: + response = _minimax_post_form( + client, + f"{portal_base_url}/oauth/token", + data={ + "grant_type": MINIMAX_OAUTH_GRANT_TYPE, + "client_id": client_id, + "user_code": user_code, + "code_verifier": code_verifier, + }, + headers=_FORM_JSON_HEADERS, + ) + error_text = "" + if response.status_code != 200: + error_text = _minimax_response_error_text(response) + try: + payload = json.loads(error_text) if error_text else {} + except Exception: + payload = {} + msg = (payload.get("base_resp", {}) or {}).get("status_msg") or error_text + raise _minimax_err(f"MiniMax OAuth error: {msg or 'unknown'}", "token_exchange_failed") + try: + payload = response.json() if response.text else {} + except Exception: + payload = {} + + status = payload.get("status") + if status == "error": + raise _minimax_err( + "MiniMax OAuth reported an error. Please try again later.", + "authorization_denied", + ) + if status == "success": + if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")): + raise _minimax_err( + "MiniMax OAuth success payload missing required token fields.", + "token_incomplete", + ) + return payload + # "pending" or any other status -> keep polling + time.sleep(interval) + + raise _minimax_err("MiniMax OAuth timed out before authorization completed.", "timeout") + + +def _minimax_save_auth_state(auth_state: Dict[str, Any]) -> None: + """Persist MiniMax OAuth state to Hermes auth store (~/.hermes/auth.json).""" + from hermes_cli.auth import _save_active_provider_state + _save_active_provider_state("minimax-oauth", auth_state) + + +def _minimax_oauth_login( + *, region: str = "global", open_browser: bool = True, + timeout_seconds: float = 15.0, +) -> Dict[str, Any]: + """Run MiniMax OAuth flow, persist tokens, return auth state dict.""" + from hermes_cli.auth import PROVIDER_REGISTRY, _can_open_graphical_browser, _is_remote_session, _minimax_pkce_pair, _minimax_request_user_code, _minimax_save_auth_state, _print_device_code_instructions + pconfig = PROVIDER_REGISTRY["minimax-oauth"] + if region == "cn": + portal_base_url = pconfig.extra["cn_portal_base_url"] + inference_base_url = pconfig.extra["cn_inference_base_url"] + else: + portal_base_url = pconfig.portal_base_url + inference_base_url = pconfig.inference_base_url + + verifier, challenge, state = _minimax_pkce_pair() + + if _is_remote_session(): + open_browser = False + + print(f"Starting Hermes login via MiniMax ({region}) OAuth...") + print(f"Portal: {portal_base_url}") + + with httpx.Client(timeout=httpx.Timeout(timeout_seconds), + headers={"Accept": "application/json"}, + follow_redirects=True) as client: + code_data = _minimax_request_user_code( + client, portal_base_url=portal_base_url, + client_id=pconfig.client_id, + code_challenge=challenge, state=state, + ) + verification_url = str(code_data["verification_uri"]) + user_code = str(code_data["user_code"]) + + _print_device_code_instructions( + verification_url, + user_code, + open_browser=open_browser and _can_open_graphical_browser(), + ) + + interval_raw = code_data.get("interval") + interval_ms = int(interval_raw) if interval_raw is not None else None + print("Waiting for approval...") + + token_data = _minimax_poll_token( + client, portal_base_url=portal_base_url, + client_id=pconfig.client_id, + user_code=user_code, code_verifier=verifier, + expired_in=int(code_data["expired_in"]), + interval_ms=interval_ms, + ) + + auth_state = { + "provider": "minimax-oauth", + "region": region, + "portal_base_url": portal_base_url, + "inference_base_url": inference_base_url, + "client_id": pconfig.client_id, + "scope": MINIMAX_OAUTH_SCOPE, + "token_type": token_data.get("token_type", "Bearer"), + "access_token": token_data["access_token"], + "refresh_token": token_data["refresh_token"], + "resource_url": token_data.get("resource_url"), + **_minimax_expiry_fields(token_data["expired_in"]), + } + + _minimax_save_auth_state(auth_state) + print("\u2713 MiniMax OAuth login successful.") + if msg := token_data.get("notification_message"): + print(f"Note from MiniMax: {msg}") + return auth_state + + +def _refresh_minimax_oauth_state( + state: Dict[str, Any], *, timeout_seconds: float = 15.0, + force: bool = False, +) -> Dict[str, Any]: + """Refresh MiniMax OAuth access token if close to expiry (or forced).""" + from hermes_cli.auth import _minimax_save_auth_state + if not state.get("refresh_token"): + raise _minimax_err( + "MiniMax OAuth state has no refresh_token; please re-login.", + "no_refresh_token", relogin=True, + ) + try: + expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp() + except Exception: + expires_at = 0.0 + now = time.time() + if not force and (expires_at - now) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS: + return state + + portal_base_url = state["portal_base_url"] + with httpx.Client(timeout=httpx.Timeout(timeout_seconds), + follow_redirects=True) as client: + response = _minimax_post_form( + client, + f"{portal_base_url}/oauth/token", + data={ + "grant_type": "refresh_token", + "client_id": state["client_id"], + "refresh_token": state["refresh_token"], + }, + headers=_FORM_JSON_HEADERS, + ) + # The non-200 branch reads a STREAMED body, so it must run while + # the client is still open — iter_bytes() after the client context + # closes raises (StreamClosed). The 200 path was already read by + # _minimax_post_form, so response.json() below is safe outside. + if response.status_code != 200: + body = _minimax_response_error_text(response) + body_lower = body.lower() + relogin = any(m in body_lower for m in + ("invalid_grant", "refresh_token_reused", "invalid_refresh_token")) + raise _minimax_err( + f"MiniMax OAuth refresh failed: {body or response.reason_phrase}", + "refresh_failed", relogin=relogin, + ) + payload = response.json() + if payload.get("status") != "success": + raise _minimax_err( + "MiniMax OAuth refresh did not return success.", + "refresh_failed", relogin=True, + ) + new_state = dict(state) + new_state.update({ + "access_token": payload["access_token"], + "refresh_token": payload.get("refresh_token", state["refresh_token"]), + **_minimax_expiry_fields(payload["expired_in"]), + }) + _minimax_save_auth_state(new_state) + return new_state + + +def _minimax_oauth_quarantine_on_terminal_refresh(state: Dict[str, Any], exc: AuthError) -> None: + """Wipe dead tokens from auth.json after a terminal refresh failure. + + Shared by the eager-resolve path and the lazy per-request token provider. Mirrors the + Nous / xAI / Codex quarantine pattern so subsequent calls fail fast without a network retry. + """ + from hermes_cli.auth import _minimax_save_auth_state, _quarantine_flat_oauth_state + if not (exc.relogin_required and state.get("refresh_token")): + return + _quarantine_flat_oauth_state(state, "minimax-oauth", exc) + try: + _minimax_save_auth_state(state) + except Exception as _save_exc: + logger.debug("MiniMax OAuth: failed to persist quarantined state: %s", _save_exc) + + +def _minimax_fresh_state() -> Dict[str, Any]: + """Load the MiniMax OAuth state and refresh it if near expiry; quarantine on terminal failure.""" + from hermes_cli.auth import _refresh_minimax_oauth_state, get_provider_auth_state + state = get_provider_auth_state("minimax-oauth") + if not state or not state.get("access_token"): + raise _minimax_err( + "Not logged into MiniMax OAuth. Run `hermes model` and select " + "MiniMax (OAuth).", + "not_logged_in", relogin=True, + ) + try: + return _refresh_minimax_oauth_state(state) + except AuthError as exc: + _minimax_oauth_quarantine_on_terminal_refresh(state, exc) + raise + + +def build_minimax_oauth_token_provider() -> Callable[[], str]: + """Return a zero-arg callable that yields a fresh MiniMax access token. + + The Anthropic SDK caches ``api_key`` as a static string at construction time, so a session that + resolves credentials once at startup will keep sending the same bearer until MiniMax's server + returns 401 — typically ~15 minutes in, because MiniMax issues short-lived access tokens. + """ + def _provide() -> str: + state = _minimax_fresh_state() + token = state.get("access_token") + if not token: + raise _minimax_err( + "MiniMax OAuth state has no access_token after refresh.", + "no_access_token", relogin=True, + ) + return token + + return _provide + + +def resolve_minimax_oauth_runtime_credentials( + *, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, + as_token_provider: bool = False, +) -> Dict[str, Any]: + """Return {provider, api_key, base_url, source} for minimax-oauth. + + The default (string ``api_key``) preserves the historical contract for diagnostic call sites + like ``hermes status`` that just want to know whether a valid token exists right now. + """ + state = _minimax_fresh_state() + if as_token_provider: + api_key: Any = build_minimax_oauth_token_provider() + else: + api_key = state["access_token"] + return { + "provider": "minimax-oauth", + "api_key": api_key, + "base_url": state["inference_base_url"].rstrip("/"), + "source": "oauth", + } + + +def _login_minimax_oauth(args, pconfig: ProviderConfig) -> None: + """CLI entry for MiniMax OAuth login.""" + from hermes_cli.auth import format_auth_error + region = getattr(args, "region", None) or "global" + open_browser = not getattr(args, "no_browser", False) + timeout = getattr(args, "timeout", None) or 15.0 + try: + _minimax_oauth_login( + region=region, open_browser=open_browser, timeout_seconds=timeout, + ) + except AuthError as exc: + print(format_auth_error(exc)) + raise SystemExit(1) diff --git a/hermes_cli/auth_spotify.py b/hermes_cli/auth_spotify.py new file mode 100644 index 0000000000..1de2a39d68 --- /dev/null +++ b/hermes_cli/auth_spotify.py @@ -0,0 +1,619 @@ +"""Spotify OAuth (loopback PKCE) login, refresh and runtime credentials. + +Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so +``hermes_cli.auth.`` keeps resolving (and monkeypatching) as before. Origin-internal +helpers are imported lazily inside each function (no import cycle; patches on +``hermes_cli.auth.`` still intercept). +""" + +from __future__ import annotations + +import logging +import base64 +import hashlib +import os +import threading +import time +import uuid +import webbrowser +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import parse_qs, urlencode, urlparse +from hermes_cli.auth_constants import ( + AuthError, + DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, + DEFAULT_SPOTIFY_API_BASE_URL, + DEFAULT_SPOTIFY_REDIRECT_URI, + DEFAULT_SPOTIFY_SCOPE, + SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + SPOTIFY_DASHBOARD_URL, + SPOTIFY_DOCS_URL, + _spotify_err, + httpx, +) + +# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth"). +logger = logging.getLogger("hermes_cli.auth") + + +def _spotify_scope_list(raw_scope: Optional[str] = None) -> List[str]: + scope_text = (raw_scope or DEFAULT_SPOTIFY_SCOPE).strip() + scopes = [part for part in scope_text.split() if part] + seen: set[str] = set() + ordered: List[str] = [] + for scope in scopes: + if scope not in seen: + seen.add(scope) + ordered.append(scope) + return ordered + + +def _spotify_scope_string(raw_scope: Optional[str] = None) -> str: + return " ".join(_spotify_scope_list(raw_scope)) + + +def _spotify_setting( + state: Optional[Dict[str, Any]], + state_key: str, + env_vars: Tuple[str, ...], + default: str, + *, + explicit: Optional[str] = None, + strip_slash: bool = False, +) -> str: + """First non-empty of explicit arg, env vars (``.env`` aware), stored state, then *default*.""" + from hermes_cli.config import get_env_value + + candidates = ( + explicit, + *(get_env_value(var) for var in env_vars), + state.get(state_key) if isinstance(state, dict) else None, + default, + ) + for candidate in candidates: + cleaned = str(candidate or "").strip() + if strip_slash: + cleaned = cleaned.rstrip("/") + if cleaned: + return cleaned + return default + + +def _spotify_client_id( + explicit: Optional[str] = None, + state: Optional[Dict[str, Any]] = None, +) -> str: + client_id = _spotify_setting( + state, "client_id", ("HERMES_SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_ID"), "", explicit=explicit, + ) + if client_id: + return client_id + raise _spotify_err( + "Spotify client_id is required. Set HERMES_SPOTIFY_CLIENT_ID or pass --client-id.", + "spotify_client_id_missing", + ) + + +def _spotify_redirect_uri( + explicit: Optional[str] = None, + state: Optional[Dict[str, Any]] = None, +) -> str: + return _spotify_setting( + state, "redirect_uri", ("HERMES_SPOTIFY_REDIRECT_URI", "SPOTIFY_REDIRECT_URI"), + DEFAULT_SPOTIFY_REDIRECT_URI, explicit=explicit, + ) + + +def _spotify_api_base_url(state: Optional[Dict[str, Any]] = None) -> str: + return _spotify_setting( + state, "api_base_url", ("HERMES_SPOTIFY_API_BASE_URL",), + DEFAULT_SPOTIFY_API_BASE_URL, strip_slash=True, + ) + + +def _spotify_accounts_base_url(state: Optional[Dict[str, Any]] = None) -> str: + return _spotify_setting( + state, "accounts_base_url", ("HERMES_SPOTIFY_ACCOUNTS_BASE_URL",), + DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, strip_slash=True, + ) + + +def _spotify_code_verifier(length: int = 64) -> str: + raw = base64.urlsafe_b64encode(os.urandom(length)).decode("ascii") + return raw.rstrip("=")[:128] + + +def _spotify_code_challenge(code_verifier: str) -> str: + digest = hashlib.sha256(code_verifier.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + +def _spotify_build_authorize_url( + *, + client_id: str, + redirect_uri: str, + scope: str, + state: str, + code_challenge: str, + accounts_base_url: str, +) -> str: + query = urlencode({ + "client_id": client_id, + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": scope, + "state": state, + "code_challenge_method": "S256", + "code_challenge": code_challenge, + }) + return f"{accounts_base_url}/authorize?{query}" + + +def _spotify_validate_redirect_uri(redirect_uri: str) -> tuple[str, int, str]: + parsed = urlparse(redirect_uri) + if parsed.scheme != "http": + raise _spotify_err( + "Spotify PKCE redirect_uri must use http://localhost or http://127.0.0.1.", + "spotify_redirect_invalid", + ) + host = parsed.hostname or "" + if host not in {"127.0.0.1", "localhost"}: + raise _spotify_err( + "Spotify PKCE redirect_uri must point to localhost or 127.0.0.1.", + "spotify_redirect_invalid", + ) + if not parsed.port: + raise _spotify_err( + "Spotify PKCE redirect_uri must include an explicit localhost port.", + "spotify_redirect_invalid", + ) + return host, parsed.port, parsed.path or "/" + + +def _make_spotify_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequestHandler], dict[str, Any]]: + result: dict[str, Any] = { + "code": None, + "state": None, + "error": None, + "error_description": None, + } + + class _SpotifyCallbackHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != expected_path: + self.send_response(404) + self.end_headers() + self.wfile.write(b"Not found.") + return + + params = parse_qs(parsed.query) + result["code"] = params.get("code", [None])[0] + result["state"] = params.get("state", [None])[0] + result["error"] = params.get("error", [None])[0] + result["error_description"] = params.get("error_description", [None])[0] + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + if result["error"]: + body = "

Spotify authorization failed.

You can close this tab." + else: + body = "

Spotify authorization received.

You can close this tab." + self.wfile.write(body.encode("utf-8")) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + return + + return _SpotifyCallbackHandler, result + + +def _spotify_wait_for_callback( + redirect_uri: str, + *, + timeout_seconds: float = 180.0, +) -> dict[str, Any]: + host, port, path = _spotify_validate_redirect_uri(redirect_uri) + handler_cls, result = _make_spotify_callback_handler(path) + + class _ReuseHTTPServer(HTTPServer): + allow_reuse_address = True + + try: + server = _ReuseHTTPServer((host, port), handler_cls) + except OSError as exc: + raise _spotify_err( + f"Could not bind Spotify callback server on {host}:{port}: {exc}", + "spotify_callback_bind_failed", + ) from exc + + thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True) + thread.start() + deadline = time.monotonic() + max(5.0, timeout_seconds) + try: + while time.monotonic() < deadline: + if result["code"] or result["error"]: + return result + time.sleep(0.1) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1.0) + raise _spotify_err( + "Spotify authorization timed out waiting for the local callback.", + "spotify_callback_timeout", + ) + + +def _spotify_token_payload_to_state( + token_payload: Dict[str, Any], + *, + client_id: str, + redirect_uri: str, + requested_scope: str, + accounts_base_url: str, + api_base_url: str, + previous_state: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + from hermes_cli.auth import _coerce_ttl_seconds + now = datetime.now(timezone.utc) + expires_in = _coerce_ttl_seconds(token_payload.get("expires_in", 0)) + expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc) + state = dict(previous_state or {}) + state.update({ + "client_id": client_id, + "redirect_uri": redirect_uri, + "accounts_base_url": accounts_base_url, + "api_base_url": api_base_url, + "scope": requested_scope, + "granted_scope": str(token_payload.get("scope") or requested_scope).strip(), + "token_type": str(token_payload.get("token_type", "Bearer") or "Bearer").strip() or "Bearer", + "access_token": str(token_payload.get("access_token", "") or "").strip(), + "refresh_token": str( + token_payload.get("refresh_token") + or state.get("refresh_token") + or "" + ).strip(), + "obtained_at": now.isoformat(), + "expires_at": expires_at.isoformat(), + "expires_in": expires_in, + "auth_type": "oauth_pkce", + }) + return state + + +def _spotify_token_post( + accounts_base_url: str, + data: Dict[str, str], + *, + timeout_seconds: float, + what: str, + failed_code: str, + invalid_code: str, + invalid_message: str, + failed_suffix: str = "", + relogin_required: bool = False, +) -> Dict[str, Any]: + """POST to Spotify's ``/api/token`` and return the JSON payload, or raise a shaped AuthError.""" + try: + response = httpx.post( + f"{accounts_base_url}/api/token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data=data, + timeout=timeout_seconds, + ) + except Exception as exc: + raise _spotify_err(f"Spotify {what} failed: {exc}", failed_code) from exc + + if response.status_code >= 400: + detail = response.text.strip() + raise _spotify_err( + f"Spotify {what} failed.{failed_suffix}" + + (f" Response: {detail}" if detail else ""), + failed_code, relogin=relogin_required, + ) + payload = response.json() + if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip(): + raise _spotify_err(invalid_message, invalid_code, relogin=relogin_required) + return payload + + +def _spotify_exchange_code_for_tokens( + *, + client_id: str, + code: str, + redirect_uri: str, + code_verifier: str, + accounts_base_url: str, + timeout_seconds: float = 20.0, +) -> Dict[str, Any]: + return _spotify_token_post( + accounts_base_url, + { + "client_id": client_id, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + }, + timeout_seconds=timeout_seconds, + what="token exchange", + failed_code="spotify_token_exchange_failed", + invalid_code="spotify_token_exchange_invalid", + invalid_message="Spotify token response did not include an access_token.", + ) + + +def _refresh_spotify_oauth_state( + state: Dict[str, Any], + *, + timeout_seconds: float = 20.0, +) -> Dict[str, Any]: + refresh_token = str(state.get("refresh_token", "") or "").strip() + if not refresh_token: + raise _spotify_err( + "Spotify refresh token missing. Run `hermes auth spotify` again.", + "spotify_refresh_token_missing", relogin=True, + ) + + client_id = _spotify_client_id(state=state) + accounts_base_url = _spotify_accounts_base_url(state) + payload = _spotify_token_post( + accounts_base_url, + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + }, + timeout_seconds=timeout_seconds, + what="token refresh", + failed_code="spotify_refresh_failed", + invalid_code="spotify_refresh_invalid", + invalid_message="Spotify refresh response did not include an access_token.", + failed_suffix=" Run `hermes auth spotify` again.", + relogin_required=True, + ) + + return _spotify_token_payload_to_state( + payload, + client_id=client_id, + redirect_uri=_spotify_redirect_uri(state=state), + requested_scope=str(state.get("scope") or DEFAULT_SPOTIFY_SCOPE), + accounts_base_url=accounts_base_url, + api_base_url=_spotify_api_base_url(state), + previous_state=state, + ) + + +def resolve_spotify_runtime_credentials( + *, + force_refresh: bool = False, + refresh_if_expiring: bool = True, + refresh_skew_seconds: int = SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, +) -> Dict[str, Any]: + from hermes_cli.auth import _auth_store_lock, _is_expiring, _load_auth_store, _load_provider_state, _quarantine_flat_oauth_state, _refresh_spotify_oauth_state, _save_auth_store, _store_provider_state + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "spotify") + if not state: + raise _spotify_err( + "Spotify is not authenticated. Run `hermes auth spotify` first.", + "spotify_auth_missing", relogin=True, + ) + + should_refresh = bool(force_refresh) + if not should_refresh and refresh_if_expiring: + should_refresh = _is_expiring(state.get("expires_at"), refresh_skew_seconds) + if should_refresh: + try: + state = _refresh_spotify_oauth_state(state) + _store_provider_state(auth_store, "spotify", state, set_active=False) + _save_auth_store(auth_store) + except AuthError as exc: + if exc.relogin_required and state.get("refresh_token"): + _quarantine_flat_oauth_state(state, "spotify", exc) + try: + _store_provider_state(auth_store, "spotify", state, set_active=False) + _save_auth_store(auth_store) + except Exception as _save_exc: + logger.debug("Spotify OAuth: failed to persist quarantined state: %s", _save_exc) + raise + + access_token = str(state.get("access_token", "") or "").strip() + if not access_token: + raise _spotify_err( + "Spotify access token missing. Run `hermes auth spotify` again.", + "spotify_access_token_missing", relogin=True, + ) + + return { + "provider": "spotify", + "access_token": access_token, + "api_key": access_token, + "token_type": str(state.get("token_type", "Bearer") or "Bearer"), + "base_url": _spotify_api_base_url(state), + "scope": str(state.get("granted_scope") or state.get("scope") or "").strip(), + "client_id": _spotify_client_id(state=state), + "redirect_uri": _spotify_redirect_uri(state=state), + "expires_at": state.get("expires_at"), + "refresh_token": str(state.get("refresh_token", "") or "").strip(), + } + + +def get_spotify_auth_status() -> Dict[str, Any]: + from hermes_cli.auth import _is_expiring, get_provider_auth_state + state = get_provider_auth_state("spotify") + if not state: + return {"logged_in": False} + + expires_at = state.get("expires_at") + refresh_token = str(state.get("refresh_token", "") or "").strip() + return { + "logged_in": bool(refresh_token or not _is_expiring(expires_at, 0)), + "auth_type": state.get("auth_type", "oauth_pkce"), + "client_id": state.get("client_id"), + "redirect_uri": state.get("redirect_uri"), + "scope": state.get("granted_scope") or state.get("scope"), + "expires_at": expires_at, + "api_base_url": state.get("api_base_url"), + "has_refresh_token": bool(refresh_token), + } + + +def _spotify_interactive_setup(redirect_uri_hint: str) -> str: + """Walk the user through creating a Spotify developer app, persist the resulting client_id to + ~/.hermes/.env, and return it. + """ + from hermes_cli.auth import _is_remote_session + from hermes_cli.config import save_env_value + + print() + print("=" * 70) + print("Spotify first-time setup") + print("=" * 70) + print() + print("Spotify requires every user to register their own lightweight") + print("developer app. This takes about two minutes and only has to be") + print("done once per machine.") + print() + print(f"Full guide: {SPOTIFY_DOCS_URL}") + print() + print("Steps:") + print(f" 1. Opening {SPOTIFY_DASHBOARD_URL} in your browser...") + print(" 2. Click 'Create app' and fill in:") + print(" App name: anything (e.g. hermes-agent)") + print(" Description: anything") + print(f" Redirect URI: {redirect_uri_hint}") + print(" API/SDK: Web API") + print(" 3. Agree to the terms, click Save.") + print(" 4. Open the app's Settings page and copy the Client ID.") + print(" 5. Paste it below.") + print() + + if not _is_remote_session(): + try: + webbrowser.open(SPOTIFY_DASHBOARD_URL) + except Exception: + pass + + from hermes_cli.cli_output import line_input + + try: + raw = line_input("Spotify Client ID: ").strip() + except (EOFError, KeyboardInterrupt): + print() + raise SystemExit("Spotify setup cancelled.") + + if not raw: + print() + print(f"No Client ID entered. See {SPOTIFY_DOCS_URL} for the full guide.") + raise SystemExit("Spotify setup cancelled: empty Client ID.") + + # Persist so subsequent `hermes auth spotify` runs skip the wizard. + save_env_value("HERMES_SPOTIFY_CLIENT_ID", raw) + # Only persist the redirect URI if it's non-default, to avoid pinning + # users to a value the default might later change to. + if redirect_uri_hint and redirect_uri_hint != DEFAULT_SPOTIFY_REDIRECT_URI: + save_env_value("HERMES_SPOTIFY_REDIRECT_URI", redirect_uri_hint) + + print() + print("Saved HERMES_SPOTIFY_CLIENT_ID to ~/.hermes/.env") + print() + return raw + + +def login_spotify_command(args) -> None: + from hermes_cli.auth import _auth_store_lock, _can_open_graphical_browser, _is_remote_session, _load_auth_store, _print_loopback_ssh_hint, _save_auth_store, _store_provider_state, get_provider_auth_state + existing_state = get_provider_auth_state("spotify") or {} + + # Interactive wizard: if no client_id is configured anywhere, walk the + # user through creating the Spotify developer app instead of crashing + # with "HERMES_SPOTIFY_CLIENT_ID is required". + explicit_client_id = getattr(args, "client_id", None) + try: + client_id = _spotify_client_id(explicit_client_id, existing_state) + except AuthError as exc: + if getattr(exc, "code", "") != "spotify_client_id_missing": + raise + client_id = _spotify_interactive_setup( + redirect_uri_hint=getattr(args, "redirect_uri", None) or DEFAULT_SPOTIFY_REDIRECT_URI, + ) + + redirect_uri = _spotify_redirect_uri(getattr(args, "redirect_uri", None), existing_state) + scope = _spotify_scope_string(getattr(args, "scope", None) or existing_state.get("scope")) + accounts_base_url = _spotify_accounts_base_url(existing_state) + api_base_url = _spotify_api_base_url(existing_state) + open_browser = not getattr(args, "no_browser", False) + + code_verifier = _spotify_code_verifier() + code_challenge = _spotify_code_challenge(code_verifier) + state_nonce = uuid.uuid4().hex + authorize_url = _spotify_build_authorize_url( + client_id=client_id, + redirect_uri=redirect_uri, + scope=scope, + state=state_nonce, + code_challenge=code_challenge, + accounts_base_url=accounts_base_url, + ) + + print("Starting Spotify PKCE login...") + print(f"Client ID: {client_id}") + print(f"Redirect URI: {redirect_uri}") + print("Make sure this redirect URI is allow-listed in your Spotify app settings.") + print() + print("Open this URL to authorize Hermes:") + print(authorize_url) + print() + print(f"Full setup guide: {SPOTIFY_DOCS_URL}") + print() + + _print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL) + + if open_browser and not _is_remote_session() and _can_open_graphical_browser(): + try: + opened = webbrowser.open(authorize_url) + except Exception: + opened = False + if opened: + print("Browser opened for Spotify authorization.") + else: + print("Could not open the browser automatically; use the URL above.") + + callback = _spotify_wait_for_callback( + redirect_uri, + timeout_seconds=float(getattr(args, "timeout", None) or 180.0), + ) + if callback.get("error"): + detail = callback.get("error_description") or callback["error"] + raise SystemExit(f"Spotify authorization failed: {detail}") + if callback.get("state") != state_nonce: + raise SystemExit("Spotify authorization failed: state mismatch.") + + token_payload = _spotify_exchange_code_for_tokens( + client_id=client_id, + code=str(callback.get("code") or ""), + redirect_uri=redirect_uri, + code_verifier=code_verifier, + accounts_base_url=accounts_base_url, + timeout_seconds=float(getattr(args, "timeout", None) or 20.0), + ) + spotify_state = _spotify_token_payload_to_state( + token_payload, + client_id=client_id, + redirect_uri=redirect_uri, + requested_scope=scope, + accounts_base_url=accounts_base_url, + api_base_url=api_base_url, + ) + + with _auth_store_lock(): + auth_store = _load_auth_store() + _store_provider_state(auth_store, "spotify", spotify_state, set_active=False) + saved_to = _save_auth_store(auth_store) + + print("Spotify login successful!") + print(f" Auth state: {saved_to}") + print(" Provider state saved under providers.spotify") + print(f" Docs: {SPOTIFY_DOCS_URL}") diff --git a/hermes_cli/auth_xai.py b/hermes_cli/auth_xai.py new file mode 100644 index 0000000000..fdd334ac1a --- /dev/null +++ b/hermes_cli/auth_xai.py @@ -0,0 +1,830 @@ +"""xAI Grok OAuth: token store, discovery, refresh, device-code login. + +Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so +``hermes_cli.auth.`` keeps resolving (and monkeypatching) as before. Origin-internal +helpers are imported lazily inside each function (no import cycle; patches on +``hermes_cli.auth.`` still intercept). +""" + +from __future__ import annotations + +import logging +import base64 +import json +import os +import time +from pathlib import Path +from typing import Any, Dict, Optional +from urllib.parse import urlparse +from hermes_cli.auth_codex import _load_auth_store_maybe_locked, _refresh_payload_access_token +from hermes_cli.auth_constants import ( + AUTH_LOCK_TIMEOUT_SECONDS, + AuthError, + DEFAULT_XAI_OAUTH_BASE_URL, + DEVICE_CODE_GRANT_TYPE, + XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + XAI_OAUTH_CLIENT_ID, + XAI_OAUTH_DEVICE_CODE_URL, + XAI_OAUTH_DISCOVERY_URL, + XAI_OAUTH_SCOPE, + _FORM_JSON_HEADERS, + _xai_err, + httpx, +) +from utils import env_float + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle + from hermes_cli.auth import ProviderConfig + +# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth"). +logger = logging.getLogger("hermes_cli.auth") + + +def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return usable xAI OAuth state from provider state or credential pool.""" + from hermes_cli.auth import _load_provider_state + state = _load_provider_state(auth_store, "xai-oauth") + tokens = state.get("tokens") if isinstance(state, dict) else None + if isinstance(tokens, dict): + access_token = str(tokens.get("access_token", "") or "").strip() + refresh_token = str(tokens.get("refresh_token", "") or "").strip() + if access_token and refresh_token: + return state + + credential_pool = auth_store.get("credential_pool") + entries = ( + credential_pool.get("xai-oauth") + if isinstance(credential_pool, dict) + else None + ) + if isinstance(entries, list): + for entry in entries: + if not isinstance(entry, dict): + continue + access_token = str(entry.get("access_token", "") or "").strip() + refresh_token = str(entry.get("refresh_token", "") or "").strip() + if not access_token or not refresh_token: + continue + merged = dict(state or {}) + merged["tokens"] = { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": str(entry.get("token_type") or "Bearer"), + } + if entry.get("last_refresh"): + merged["last_refresh"] = entry.get("last_refresh") + merged.setdefault("auth_mode", "oauth_pkce") + return merged + + return state if isinstance(state, dict) else None + + +def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool: + tokens = state.get("tokens") if isinstance(state, dict) else None + return ( + isinstance(tokens, dict) + and bool(str(tokens.get("access_token", "") or "").strip()) + and bool(str(tokens.get("refresh_token", "") or "").strip()) + ) + + +def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]: + from hermes_cli.auth import _load_global_auth_store + auth_store = _load_auth_store_maybe_locked(_lock) + state = _xai_oauth_state_from_store(auth_store) + if not _xai_oauth_state_has_usable_tokens(state): + global_state = _xai_oauth_state_from_store(_load_global_auth_store()) + if _xai_oauth_state_has_usable_tokens(global_state): + state = global_state + if not state: + raise _xai_err( + "No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.", + "xai_auth_missing", relogin=True, + ) + tokens = state.get("tokens") + if not isinstance(tokens, dict): + raise _xai_err( + "xAI OAuth state is missing tokens. Re-authenticate with `hermes model`.", + "xai_auth_invalid_shape", relogin=True, + ) + access_token = str(tokens.get("access_token", "") or "").strip() + refresh_token = str(tokens.get("refresh_token", "") or "").strip() + if not access_token: + raise _xai_err( + "xAI OAuth state is missing access_token. Re-authenticate with `hermes model`.", + "xai_auth_missing_access_token", relogin=True, + ) + if not refresh_token: + raise _xai_err( + "xAI OAuth state is missing refresh_token. Re-authenticate with `hermes model`.", + "xai_auth_missing_refresh_token", relogin=True, + ) + return { + "tokens": tokens, + "last_refresh": state.get("last_refresh"), + "discovery": state.get("discovery") or {}, + "redirect_uri": state.get("redirect_uri"), + } + + +def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None: + """Persist a rotated xAI OAuth ``state`` into the global-root auth.json. + + Best-effort write-through for the multi-profile rotation hazard (#43589): xAI rotates the + refresh_token on every refresh, so when a profile session refreshes a grant it resolved from the + root fallback, the rotated chain must land back in root. + + Only updates ``providers.xai-oauth`` in the root store; never touches the profile store (the + caller already saved that). Swallows all errors — a failed write-through degrades to the pre- + existing behavior (root stale), it must never break the profile's own successful save. + """ + from hermes_cli.auth import _global_auth_file_path, _persist_provider_state_to_store + global_path = _global_auth_file_path() + if global_path is None: + # Classic mode (profile == root); the profile save already hit root. + return + # Seat belt: under pytest, refuse to write the real user's + # ~/.hermes/auth.json even when HERMES_HOME points at a profile path + # (mirrors the read-side guard in _load_global_auth_store). Uses the + # unmodified HOME env, not Path.home() which fixtures may monkeypatch. + if os.environ.get("PYTEST_CURRENT_TEST"): + real_home_env = os.environ.get("HOME", "") + if real_home_env: + real_root = Path(real_home_env) / ".hermes" / "auth.json" + try: + if global_path.resolve(strict=False) == real_root.resolve(strict=False): + return + except Exception: + return + try: + _persist_provider_state_to_store( + "xai-oauth", + state, + global_path, + set_active=False, + ) + except Exception as exc: # pragma: no cover - best effort + logger.debug("xAI OAuth: write-through to global root failed: %s", exc) + + +def _save_xai_oauth_tokens( + tokens: Dict[str, Any], + *, + discovery: Optional[Dict[str, Any]] = None, + redirect_uri: str = "", + last_refresh: Optional[str] = None, + auth_mode: str = "oauth_device_code", + set_active: bool = True, +) -> None: + """Persist xAI OAuth tokens into the auth store. + + When *set_active* is True (default), also promote ``xai-oauth`` to ``active_provider`` — + appropriate for intentional model/auth login. Pass ``set_active=False`` for side-tool credential + bootstrap (TTS/setup, tools config, dashboard token save, token refresh) so inference routing is + unchanged. + """ + from hermes_cli.auth import _auth_store_lock, _global_auth_file_path, _load_auth_store, _load_provider_state_with_source, _same_path, _save_auth_store, _store_provider_state, _utc_now_z, _write_through_xai_oauth_to_global_root + if last_refresh is None: + last_refresh = _utc_now_z() + with _auth_store_lock(): + auth_store = _load_auth_store() + # A profile that lacks its own xai-oauth block is reading the root + # grant through _load_provider_state's fallback. When such a profile + # refreshes the (rotating) grant, we must write the rotated chain back + # to root too, or root is left holding a revoked refresh token (#43589). + # #74339: the old key-presence check (_profile_has_own_xai_oauth_state) + # decided write-through based on whether the profile had a + # providers.xai-oauth key BEFORE the save — but _store_provider_state + # unconditionally creates that key below. Use + # _load_provider_state_with_source to learn where the grant was + # resolved from and write back only to that source. + state, source_path = _load_provider_state_with_source( + auth_store, "xai-oauth" + ) + if state is None: + state = {} + state["tokens"] = tokens + state["last_refresh"] = last_refresh + state["auth_mode"] = auth_mode + if discovery: + state["discovery"] = discovery + if redirect_uri: + state["redirect_uri"] = redirect_uri + global_root = _global_auth_file_path() + is_from_root = bool( + source_path is not None + and global_root is not None + and _same_path(source_path, global_root) + ) + if is_from_root: + # Grant was resolved from root — write back to root only. + # Do NOT call _store_provider_state on the profile auth_store + # (it would create a shadowing providers.xai-oauth key that + # disables write-through on the next refresh — #74339). + _write_through_xai_oauth_to_global_root(state) + else: + # Profile genuinely owns this — write to profile store. + _store_provider_state( + auth_store, "xai-oauth", state, set_active=set_active + ) + _save_auth_store(auth_store) + + +def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool: + if not isinstance(access_token, str) or "." not in access_token: + return False + try: + parts = access_token.split(".") + if len(parts) < 2: + return False + payload_b64 = parts[1] + payload_b64 += "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8")) + exp = payload.get("exp") + if not isinstance(exp, (int, float)): + return False + return float(exp) <= (time.time() + max(0, int(skew_seconds))) + except Exception: + return False + + +def _xai_proactive_refresh_skew_seconds(access_token: str) -> int: + """How far before JWT ``exp`` to proactively refresh xAI OAuth tokens. + + SuperGrok sessions ship multi-hour tokens where the gateway-oriented hour-long skew makes sense, + but device-code logins often return ~15-minute JWTs; the full skew would force a refresh on + every credential resolution, burning single-use refresh tokens and racing concurrent callers + into ``invalid_grant`` quarantine. + """ + max_skew = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS + if not isinstance(access_token, str) or "." not in access_token: + return max_skew + try: + parts = access_token.split(".") + if len(parts) < 2: + return max_skew + payload_b64 = parts[1] + payload_b64 += "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8")) + exp = payload.get("exp") + if not isinstance(exp, (int, float)): + return max_skew + remaining = float(exp) - time.time() + if remaining <= 0: + return max_skew + if remaining <= 45 * 60: + return min(120, max_skew) + return max_skew + except Exception: + return max_skew + + +def _is_xai_origin_host(host: str) -> bool: + """``x.ai`` is the bare apex, so an exact match or any ``.x.ai`` suffix is accepted.""" + return host == "x.ai" or host.endswith(".x.ai") + + +def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str: + """Refuse any OIDC discovery endpoint that isn't HTTPS on the xAI origin. + + The discovery result is cached in auth.json, so a single MITM at login could plant a malicious + ``token_endpoint`` that receives the refresh_token forever. Pinning scheme + host (RFC 8414 §2: + HTTPS issuer, same-origin token_endpoint) removes that persistence; ``x.ai`` is the bare apex, + so an exact match or any ``.x.ai`` suffix is accepted. + """ + parsed = urlparse(url) + if parsed.scheme != "https": + raise _xai_err( + f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}.", + "xai_discovery_invalid", + ) + host = (parsed.hostname or "").lower() + if not host: + raise _xai_err( + f"xAI OIDC discovery {field} is missing a hostname: {url!r}.", + "xai_discovery_invalid", + ) + if not _is_xai_origin_host(host): + raise _xai_err( + f"xAI OIDC discovery {field} host {host!r} is not on the xAI origin " + f"(expected x.ai or a *.x.ai subdomain). Refusing to use a cached " + f"endpoint that may have been substituted by a MITM during initial " + f"discovery; re-authenticate with `hermes model` to re-fetch.", + "xai_discovery_invalid", + ) + return url + + +def _xai_validate_inference_base_url(value: str, *, fallback: str) -> str: + """Refuse a non-xAI base_url for the OAuth-authenticated inference path. + + Pin the inference origin to ``api.x.ai`` (or any ``*.x.ai`` subdomain xAI may add). On + rejection, fall back to the default and log a warning rather than raise — a bad env var should + not deadlock authentication, but it should also never leak the bearer. + + ``value`` is the already-stripped, trailing-slash-trimmed candidate from env. Empty input + returns ``fallback`` unchanged. + """ + candidate = (value or "").strip().rstrip("/") + if not candidate: + return fallback + try: + parsed = urlparse(candidate) + except Exception: + logger.warning( + "Ignoring malformed xAI base_url override %r; using %s instead.", + candidate, fallback, + ) + return fallback + if parsed.scheme != "https": + logger.warning( + "Refusing non-HTTPS xAI base_url override %r (xai-oauth bearer would " + "be sent in cleartext); falling back to %s.", + candidate, fallback, + ) + return fallback + host = (parsed.hostname or "").lower() + if not host: + logger.warning( + "Ignoring xAI base_url override %r with no hostname; using %s instead.", + candidate, fallback, + ) + return fallback + if not _is_xai_origin_host(host): + logger.warning( + "Refusing xAI base_url override %r — host %r is not on the xAI origin " + "(expected x.ai or a *.x.ai subdomain). The xai-oauth bearer is only " + "valid against xAI's inference API; sending it elsewhere would leak " + "the credential. Falling back to %s.", + candidate, host, fallback, + ) + return fallback + return candidate + + +def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]: + try: + response = httpx.get( + XAI_OAUTH_DISCOVERY_URL, + headers={"Accept": "application/json"}, + timeout=timeout_seconds, + ) + except Exception as exc: + raise _xai_err(f"xAI OIDC discovery failed: {exc}", "xai_discovery_failed") from exc + if response.status_code != 200: + raise _xai_err( + f"xAI OIDC discovery returned status {response.status_code}.", + "xai_discovery_failed", + ) + try: + payload = response.json() + except Exception as exc: + raise _xai_err( + f"xAI OIDC discovery returned invalid JSON: {exc}", + "xai_discovery_invalid_json", + ) from exc + if not isinstance(payload, dict): + raise _xai_err( + "xAI OIDC discovery response was not a JSON object.", + "xai_discovery_incomplete", + ) + authorization_endpoint = str(payload.get("authorization_endpoint", "") or "").strip() + token_endpoint = str(payload.get("token_endpoint", "") or "").strip() + if not authorization_endpoint or not token_endpoint: + raise _xai_err( + "xAI OIDC discovery response was missing required endpoints.", + "xai_discovery_incomplete", + ) + _xai_validate_oauth_endpoint(authorization_endpoint, field="authorization_endpoint") + _xai_validate_oauth_endpoint(token_endpoint, field="token_endpoint") + return { + "authorization_endpoint": authorization_endpoint, + "token_endpoint": token_endpoint, + } + + +def _xai_tokens_from_payload(payload: Dict[str, Any], access_token: str, fallback_refresh: str) -> Dict[str, Any]: + """Token block persisted for xAI OAuth; falls back to *fallback_refresh* when none is rotated in.""" + return { + "access_token": access_token, + "refresh_token": str(payload.get("refresh_token") or fallback_refresh).strip(), + "id_token": str(payload.get("id_token") or "").strip(), + "expires_in": payload.get("expires_in"), + "token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer", + } + + +def refresh_xai_oauth_pure( + access_token: str, + refresh_token: str, + *, + token_endpoint: str = "", + timeout_seconds: float = 20.0, +) -> Dict[str, Any]: + from hermes_cli.auth import _nonempty_str, _utc_now_z, _xai_oauth_discovery + del access_token + if not _nonempty_str(refresh_token): + raise _xai_err( + "xAI OAuth is missing refresh_token. Re-authenticate with `hermes model`.", + "xai_auth_missing_refresh_token", relogin=True, + ) + endpoint = token_endpoint.strip() or _xai_oauth_discovery(timeout_seconds)["token_endpoint"] + # Re-validate cached endpoints on the refresh hot path: an auth.json + # written by an older Hermes (or hand-edited) may carry a non-xAI + # token_endpoint that would receive every future refresh_token in + # plaintext if we trusted it blindly. Cheap suffix check; fast-fail + # with a clear error so the user can re-run `hermes model` to refetch. + _xai_validate_oauth_endpoint(endpoint, field="token_endpoint") + timeout = httpx.Timeout(max(5.0, float(timeout_seconds))) + with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client: + response = client.post( + endpoint, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "grant_type": "refresh_token", + "client_id": XAI_OAUTH_CLIENT_ID, + "refresh_token": refresh_token, + }, + ) + if response.status_code != 200: + detail = response.text.strip() + # ``403`` from xAI's token endpoint is almost always a tier / + # entitlement gate (the OAuth grant exists but the account isn't + # on the allowlist for API access). Re-running ``hermes model`` + # won't fix that — surface a separate error code so + # ``format_auth_error`` doesn't append a misleading + # re-authenticate hint, and point users at the ``XAI_API_KEY`` + # fallback. See #26847. + if response.status_code == 403: + raise _xai_err( + "xAI token refresh failed with HTTP 403." + + (f" Response: {detail}" if detail else "") + + " This OAuth account is not authorized for xAI API" + " access — xAI may be restricting API/OAuth use to" + " specific SuperGrok tiers despite the in-app" + " subscription being active. Re-logging in won't" + " change that; set ``XAI_API_KEY`` and switch to" + " ``provider: xai`` (API-key path) if available, or" + " upgrade your subscription at https://x.ai/grok.", + "xai_oauth_tier_denied", relogin=False, + ) + raise _xai_err( + "xAI token refresh failed." + + (f" Response: {detail}" if detail else ""), + "xai_refresh_failed", relogin=response.status_code in {400, 401}, + ) + payload, refreshed_access = _refresh_payload_access_token( + response, + provider="xai-oauth", + invalid_json=("xAI token refresh returned invalid JSON: {exc}", "xai_refresh_invalid_json"), + invalid_json_relogin=False, + strict_str=False, + invalid_response=( + "xAI token refresh response was not a JSON object.", + "xai_refresh_invalid_response", + ), + missing_access=( + "xAI token refresh response was missing access_token.", + "xai_refresh_missing_access_token", + ), + ) + return { + **_xai_tokens_from_payload(payload, refreshed_access, refresh_token), + "last_refresh": _utc_now_z(), + } + + +def _refresh_xai_oauth_tokens( + tokens: Dict[str, Any], + *, + token_endpoint: str, + redirect_uri: str = "", + timeout_seconds: float, +) -> Dict[str, Any]: + # Re-persist whatever auth_mode is already stored (legacy pre-device-code + # logins may still carry ``oauth_pkce``): the refresh hot path must not + # relabel how the grant was originally obtained. + from hermes_cli.auth import _load_auth_store, _load_provider_state, refresh_xai_oauth_pure + try: + state = _load_provider_state(_load_auth_store(), "xai-oauth") or {} + auth_mode = str(state.get("auth_mode") or "oauth_device_code") + except Exception: + auth_mode = "oauth_device_code" + refreshed = refresh_xai_oauth_pure( + str(tokens.get("access_token", "") or ""), + str(tokens.get("refresh_token", "") or ""), + token_endpoint=token_endpoint, + timeout_seconds=timeout_seconds, + ) + updated_tokens = dict(tokens) + updated_tokens["access_token"] = refreshed["access_token"] + updated_tokens["refresh_token"] = refreshed["refresh_token"] + if refreshed.get("id_token"): + updated_tokens["id_token"] = refreshed["id_token"] + if refreshed.get("expires_in") is not None: + updated_tokens["expires_in"] = refreshed["expires_in"] + if refreshed.get("token_type"): + updated_tokens["token_type"] = refreshed["token_type"] + _save_xai_oauth_tokens( + updated_tokens, + discovery={"token_endpoint": token_endpoint}, + redirect_uri=redirect_uri, + last_refresh=refreshed["last_refresh"], + auth_mode=auth_mode, + # Refresh must not flip active_provider — TTS/side tools can refresh + # xAI tokens while chat still routes through another provider. + set_active=False, + ) + return updated_tokens + + +def _quarantine_xai_oauth_tokens(exc: AuthError) -> None: + """Clear dead xAI tokens from auth.json after a terminal refresh failure. + + Terminal = HTTP 400/401/403 (invalid_grant, token revoked). Subsequent sessions then fail fast + without a network retry. Mirrors credential_pool.py quarantine. Best-effort: persistence + failures are logged and swallowed (caller re-raises the original error regardless). + """ + from hermes_cli.auth import _last_auth_error_marker, _load_auth_store, _load_provider_state, _save_auth_store, _store_provider_state + try: + _q_store = _load_auth_store() + _q_state = _load_provider_state(_q_store, "xai-oauth") or {} + _q_tokens = dict(_q_state.get("tokens") or {}) + _q_tokens.pop("access_token", None) + _q_tokens.pop("refresh_token", None) + _q_state["tokens"] = _q_tokens + _q_state["last_auth_error"] = _last_auth_error_marker( + "xai-oauth", exc, + reason="runtime_refresh_failure", default_code="xai_refresh_failed", + ) + _store_provider_state(_q_store, "xai-oauth", _q_state, set_active=False) + _save_auth_store(_q_store) + except Exception as _save_exc: + logger.debug( + "xAI OAuth: failed to persist quarantined state: %s", _save_exc, + ) + + +def _xai_oauth_inference_base_url() -> str: + return _xai_validate_inference_base_url( + os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/") + or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"), + fallback=DEFAULT_XAI_OAUTH_BASE_URL, + ) + + +def resolve_xai_oauth_runtime_credentials( + *, + force_refresh: bool = False, + refresh_if_expiring: bool = True, + refresh_skew_seconds: Optional[int] = None, +) -> Dict[str, Any]: + from hermes_cli.auth import _auth_store_lock, _is_terminal_xai_oauth_refresh_error, _refresh_xai_oauth_tokens, _xai_oauth_discovery + def _view(data: Dict[str, Any]) -> tuple[Dict[str, Any], str, str, str, bool]: + tokens = dict(data["tokens"]) + access_token = str(tokens.get("access_token", "") or "").strip() + discovery = dict(data.get("discovery") or {}) + token_endpoint = str(discovery.get("token_endpoint", "") or "").strip() + redirect_uri = str(data.get("redirect_uri", "") or "").strip() + effective_skew = ( + int(refresh_skew_seconds) + if refresh_skew_seconds is not None + else _xai_proactive_refresh_skew_seconds(access_token) + ) + should_refresh = bool(force_refresh) + if (not should_refresh) and refresh_if_expiring: + should_refresh = _xai_access_token_is_expiring(access_token, effective_skew) + return tokens, access_token, token_endpoint, redirect_uri, should_refresh + + data = _read_xai_oauth_tokens() + refresh_timeout_seconds = env_float("HERMES_XAI_REFRESH_TIMEOUT_SECONDS", 20) + tokens, access_token, token_endpoint, redirect_uri, should_refresh = _view(data) + if should_refresh: + with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)): + data = _read_xai_oauth_tokens(_lock=False) + tokens, access_token, token_endpoint, redirect_uri, should_refresh = _view(data) + if should_refresh: + if not token_endpoint: + token_endpoint = _xai_oauth_discovery(refresh_timeout_seconds)["token_endpoint"] + try: + tokens = _refresh_xai_oauth_tokens( + tokens, + token_endpoint=token_endpoint, + redirect_uri=redirect_uri, + timeout_seconds=refresh_timeout_seconds, + ) + access_token = str(tokens.get("access_token", "") or "").strip() + except AuthError as exc: + if _is_terminal_xai_oauth_refresh_error(exc): + _quarantine_xai_oauth_tokens(exc) + raise + + base_url = _xai_oauth_inference_base_url() + return { + "provider": "xai-oauth", + "base_url": base_url, + "api_key": access_token, + "source": "hermes-auth-store", + "last_refresh": data.get("last_refresh"), + # Display/telemetry only. Device-code is the only supported xAI OAuth + # flow, so report it unconditionally — auth.json may still carry a + # legacy ``oauth_pkce`` label, which the refresh path preserves as-is. + "auth_mode": "oauth_device_code", + } + + +def _login_xai_oauth( + args, + pconfig: ProviderConfig, + *, + force_new_login: bool = False, +) -> None: + from hermes_cli.auth import _is_remote_session, _offer_existing_oauth_credentials, _print_login_success, _update_config_for_provider, _xai_oauth_device_code_login, resolve_xai_oauth_runtime_credentials, unsuppress_credential_source + del pconfig + + if not force_new_login and _offer_existing_oauth_credentials( + "xai-oauth", + resolve=resolve_xai_oauth_runtime_credentials, + is_expiring=_xai_access_token_is_expiring, + display_name="xAI OAuth", + default_base_url=DEFAULT_XAI_OAUTH_BASE_URL, + ): + return + + print() + print("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...") + print("(Hermes creates its own local OAuth session)") + print() + + timeout_seconds = float(getattr(args, "timeout", None) or 20.0) + open_browser = not getattr(args, "no_browser", False) + if _is_remote_session(): + open_browser = False + + creds = _xai_oauth_device_code_login( + timeout_seconds=timeout_seconds, + open_browser=open_browser, + ) + _save_xai_oauth_tokens( + creds["tokens"], + discovery=creds.get("discovery"), + redirect_uri=creds.get("redirect_uri", ""), + last_refresh=creds.get("last_refresh"), + auth_mode="oauth_device_code", + ) + # An explicit interactive re-login is a strong signal the user wants the + # xAI credential re-enabled. ``hermes auth remove xai-oauth`` leaves a + # ``device_code`` suppression marker that otherwise stops the singleton + # seed from re-creating the pool entry, so ``hermes auth list`` would show + # nothing even though the agent still works via the singleton fallback. + # Clear it here (same helper ``auth_add_command`` uses). This is kept OUT + # of ``_save_xai_oauth_tokens`` on purpose — that helper is shared with the + # refresh hot path, which must never mutate suppression state. + unsuppress_credential_source("xai-oauth", "device_code") + config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL)) + _print_login_success("xai-oauth", config_path, show_auth_state=True) + + +def _xai_oauth_request_device_code( + client: httpx.Client, + *, + scope: str = XAI_OAUTH_SCOPE, +) -> Dict[str, Any]: + response = client.post( + XAI_OAUTH_DEVICE_CODE_URL, + headers=_FORM_JSON_HEADERS, + data={ + "client_id": XAI_OAUTH_CLIENT_ID, + "scope": scope, + }, + ) + if response.status_code != 200: + raise _xai_err( + f"xAI device-code request failed (HTTP {response.status_code})." + + (f" Response: {response.text.strip()}" if response.text else ""), + "device_code_request_failed", + ) + payload = response.json() + required = ( + "device_code", + "user_code", + "verification_uri", + "verification_uri_complete", + "expires_in", + "interval", + ) + missing = [key for key in required if key not in payload] + if missing: + raise _xai_err( + f"xAI device-code response missing fields: {', '.join(missing)}", + "device_code_invalid", + ) + return payload + + +def _xai_oauth_poll_device_token( + client: httpx.Client, + *, + token_endpoint: str, + device_code: str, + expires_in: int, + poll_interval: int, +) -> Dict[str, Any]: + from hermes_cli.auth import _poll_device_token_generic + def _validate(payload: Dict[str, Any]) -> None: + for field_name, article in (("access_token", "an"), ("refresh_token", "a")): + if not payload.get(field_name): + raise _xai_err( + f"xAI device-code token response did not include {article} {field_name}.", + "xai_device_token_invalid", + ) + + def _error(response, error_payload) -> Exception: + description = ( + error_payload.get("error_description") + or error_payload.get("error") + or response.text + ) + return _xai_err( + f"xAI device-code token polling failed: {description}", + "xai_device_token_failed", + ) + + return _poll_device_token_generic( + lambda: client.post( + token_endpoint, + headers=_FORM_JSON_HEADERS, + data={ + "grant_type": DEVICE_CODE_GRANT_TYPE, + "client_id": XAI_OAUTH_CLIENT_ID, + "device_code": device_code, + }, + ), + expires_in=int(expires_in), + poll_interval=max(1, int(poll_interval)), + validate_success=_validate, + on_non_json_error=lambda _r: _xai_err( + "xAI device-code token polling returned a non-JSON error response.", + "xai_device_token_failed", + ), + on_error=_error, + on_timeout=lambda: _xai_err( + "Timed out waiting for xAI device authorization.", + "device_code_timeout", + ), + ) + + +def _xai_oauth_device_code_login( + *, + timeout_seconds: float = 20.0, + open_browser: bool = True, +) -> Dict[str, Any]: + from hermes_cli.auth import _can_open_graphical_browser, _is_remote_session, _print_device_code_instructions, _utc_now_z, _xai_oauth_discovery, _xai_oauth_poll_device_token + discovery = _xai_oauth_discovery(timeout_seconds) + token_endpoint = discovery["token_endpoint"] + timeout = httpx.Timeout(max(20.0, timeout_seconds)) + with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client: + device_data = _xai_oauth_request_device_code(client) + verification_url = str( + device_data.get("verification_uri_complete") + or device_data["verification_uri"] + ) + user_code = str(device_data["user_code"]) + expires_in = int(device_data["expires_in"]) + interval = int(device_data["interval"]) + + _print_device_code_instructions( + verification_url, + user_code, + open_browser=open_browser and not _is_remote_session() and _can_open_graphical_browser(), + swallow_open_errors=True, + ) + print(f"Waiting for approval (polling every {max(1, interval)}s)...") + + payload = _xai_oauth_poll_device_token( + client, + token_endpoint=token_endpoint, + device_code=str(device_data["device_code"]), + expires_in=expires_in, + poll_interval=interval, + ) + + access_token = str(payload.get("access_token", "") or "").strip() + refresh_token = str(payload.get("refresh_token", "") or "").strip() + if not access_token or not refresh_token: + raise _xai_err( + "xAI device-code token response was missing required tokens.", + "xai_device_token_invalid", + ) + base_url = _xai_oauth_inference_base_url() + return { + "tokens": _xai_tokens_from_payload(payload, access_token, refresh_token), + "discovery": discovery, + "redirect_uri": "", + "base_url": base_url, + "last_refresh": _utc_now_z(), + "source": "oauth-device-code", + }