From 1e5ee33f681efd6c12ae65b9514e68cc46daf269 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 16 Apr 2026 15:08:49 -0700 Subject: [PATCH] feat(gemini): add Google Gemini (OAuth) inference provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'google-gemini-cli' as a first-class inference provider using Authorization Code + PKCE (S256) OAuth against Google's accounts.google.com, hitting the OpenAI-compatible Gemini endpoint (v1beta/openai) with a Bearer access token. Users sign in with their Google account — no API-key copy-paste. Synthesized from three competing PRs per multi-PR design analysis: - Clean PKCE module structure shaped after #10176 (thanks @sliverp) - Cross-process file lock (fcntl POSIX / msvcrt Windows) with thread-local re-entrancy counter from #10779 (thanks @newarthur) - Rejects #6745's subprocess approach entirely (different paradigm) Improvements over the competing PRs: - Port fallback: if 8085 is taken, bind ephemeral port instead of failing - Preserves refresh_token when Google omits one (correct per Google spec) - Accepts both full redirect URL and bare code in paste fallback - doctor.py health check (neither PR had this) - No regression in _OAUTH_CAPABLE_PROVIDERS (#10779 dropped anthropic/nous) - No bundled unrelated features (#10779 mixed in persona/personality routing) Storage: - ~/.hermes/auth/google_oauth.json (0o600, atomic write via fsync+replace) - Cross-process fcntl/msvcrt lock with 30s timeout - Refresh 5 min before expiry on every request via get_valid_access_token Provider registration (9-point checklist): - auth.py: PROVIDER_REGISTRY entry, aliases (gemini-cli, gemini-oauth), resolve_gemini_oauth_runtime_credentials, get_gemini_oauth_auth_status, get_auth_status() dispatch - models.py: _PROVIDER_MODELS catalog, CANONICAL_PROVIDERS entry, aliases - providers.py: HermesOverlay, ALIASES entries - runtime_provider.py: resolve_runtime_provider() dispatch branch - config.py: OPTIONAL_ENV_VARS for HERMES_GEMINI_CLIENT_ID/_SECRET/_BASE_URL - main.py: _model_flow_google_gemini_cli, select_provider_and_model dispatch - auth_commands.py: add-to-pool handler, _OAUTH_CAPABLE_PROVIDERS - doctor.py: 'Google Gemini OAuth' status line Client ID: Not shipped. Users register a Desktop OAuth client in Google Cloud Console (Generative Language API) and set HERMES_GEMINI_CLIENT_ID in ~/.hermes/.env. Documented in website/docs/integrations/providers.md. Tests: 44 new unit tests covering PKCE S256 roundtrip, credential I/O (permissions + atomic write), cross-process lock, port fallback, paste fallback (URL + bare code), token exchange/refresh, rotation handling, get_valid_access_token refresh semantics, runtime provider dispatch, alias resolution, and regression guards for _OAUTH_CAPABLE_PROVIDERS. Docs: new 'Google Gemini via OAuth' section in providers.md with full walkthrough including GCP Desktop OAuth client registration, and env var table updated in environment-variables.md. Closes partial work in #6745, #10176, #10779 (to be closed with credit once this merges). --- agent/google_oauth.py | 797 ++++++++++++++++++ hermes_cli/auth.py | 99 ++- hermes_cli/auth_commands.py | 25 +- hermes_cli/config.py | 24 + hermes_cli/doctor.py | 14 +- hermes_cli/main.py | 59 ++ hermes_cli/models.py | 14 + hermes_cli/providers.py | 13 + hermes_cli/runtime_provider.py | 23 + tests/agent/test_google_oauth.py | 570 +++++++++++++ website/docs/integrations/providers.md | 56 ++ .../docs/reference/environment-variables.md | 3 + 12 files changed, 1693 insertions(+), 4 deletions(-) create mode 100644 agent/google_oauth.py create mode 100644 tests/agent/test_google_oauth.py diff --git a/agent/google_oauth.py b/agent/google_oauth.py new file mode 100644 index 000000000..657d71571 --- /dev/null +++ b/agent/google_oauth.py @@ -0,0 +1,797 @@ +"""Google OAuth PKCE flow for the Gemini (google-gemini-cli) inference provider. + +This module implements the browser-based Authorization Code + PKCE (S256) flow +against Google's OAuth 2.0 endpoints so users can authenticate Hermes with their +Google account and hit the Gemini OpenAI-compatible endpoint +(``https://generativelanguage.googleapis.com/v1beta/openai``) with a Bearer +access token instead of copy-pasting an API key. + +Synthesized from competing PRs #10176 (@sliverp) and #10779 (@newarthur): + +- PKCE generator, save/load, exchange, refresh, login flow, email fetch — shaped + after #10176's clean module layout. +- Cross-process file lock (fcntl on POSIX, msvcrt on Windows) with a thread-local + re-entrancy counter — from #10779. This prevents two Hermes processes racing + a token refresh and clobbering the rotated refresh_token. + +Flow summary +------------ +1. Generate a PKCE verifier/challenge pair (S256). +2. Spin up a localhost HTTP server on 127.0.0.1 to receive the OAuth callback. + If port 8085 is taken, we fall back to an ephemeral port and retry. +3. Open ``accounts.google.com/o/oauth2/v2/auth?...`` in the user's browser. +4. Capture the ``code`` from the callback (or accept a manual paste in headless + environments) and exchange it for access + refresh tokens. +5. Save tokens atomically to ``~/.hermes/auth/google_oauth.json`` (0o600). +6. On every runtime request, ``get_valid_access_token()`` loads the file, refreshes + the access token if it expires within ``REFRESH_SKEW_SECONDS``, and returns + a fresh bearer token. + +Client ID / secret +------------------ +No OAuth client is shipped by default. A maintainer must register a "Desktop" +OAuth client in Google Cloud Console (Generative Language API enabled) and set +``HERMES_GEMINI_CLIENT_ID`` (and optionally ``HERMES_GEMINI_CLIENT_SECRET``) in +``~/.hermes/.env``. See ``website/docs/integrations/providers.md`` for the full +registration walkthrough. + +Storage format (``~/.hermes/auth/google_oauth.json``):: + + { + "client_id": "...", + "client_secret": "...", + "access_token": "...", + "refresh_token": "...", + "expires_at": 1744848000.0, // unix seconds, float + "email": "user@example.com" // optional, display-only + } + +Public API +---------- +- ``start_oauth_flow(force_relogin=False)`` — interactive browser login +- ``get_valid_access_token()`` — runtime entry, refreshes as needed +- ``load_credentials()`` / ``save_credentials()`` / ``clear_credentials()`` +- ``refresh_access_token()`` — standalone refresh helper +- ``run_gemini_oauth_login_pure()`` — credential-pool-compatible variant +""" + +from __future__ import annotations + +import base64 +import contextlib +import hashlib +import http.server +import json +import logging +import os +import secrets +import socket +import stat +import threading +import time +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Constants +# ============================================================================= + +AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth" +TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" +USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v1/userinfo" + +# Scopes: generative-language lets us hit v1beta/openai; userinfo.email is for +# the display label in `hermes auth list`. Joined with a space per RFC 6749. +OAUTH_SCOPES = ( + "https://www.googleapis.com/auth/generative-language " + "https://www.googleapis.com/auth/userinfo.email" +) + +# Preferred loopback port. If busy we ask the OS for an ephemeral port. +# 8085 matches Google's own gemini-cli so users who already authorized a redirect +# URI on that port keep working. +DEFAULT_REDIRECT_PORT = 8085 +REDIRECT_HOST = "127.0.0.1" +CALLBACK_PATH = "/oauth2callback" + +# Refresh the access token when fewer than this many seconds remain. +REFRESH_SKEW_SECONDS = 300 # 5 minutes + +# Default timeouts +TOKEN_REQUEST_TIMEOUT_SECONDS = 20.0 +CALLBACK_WAIT_SECONDS = 300 # 5 min for user to complete browser flow +LOCK_TIMEOUT_SECONDS = 30.0 + +# Environment overrides (documented in reference/environment-variables.md) +ENV_CLIENT_ID = "HERMES_GEMINI_CLIENT_ID" +ENV_CLIENT_SECRET = "HERMES_GEMINI_CLIENT_SECRET" + +# Module-level default client credentials. Empty until a maintainer registers +# a "Hermes Agent" desktop OAuth client in Google Cloud Console and pastes the +# values here (or sets them as env vars at runtime). See module docstring. +_DEFAULT_CLIENT_ID = "" +_DEFAULT_CLIENT_SECRET = "" + + +# ============================================================================= +# Error types +# ============================================================================= + +class GoogleOAuthError(RuntimeError): + """Raised for any failure in the Google OAuth flow.""" + + def __init__(self, message: str, *, code: str = "google_oauth_error") -> None: + super().__init__(message) + self.code = code + + +# ============================================================================= +# File paths & cross-process locking +# ============================================================================= + +def _credentials_path() -> Path: + """Location of the Gemini OAuth creds file.""" + return get_hermes_home() / "auth" / "google_oauth.json" + + +def _lock_path() -> Path: + return _credentials_path().with_suffix(".json.lock") + + +# Re-entrancy depth counter so nested calls to _credentials_lock() from the same +# thread don't deadlock. Per-thread via threading.local. +_lock_state = threading.local() + + +@contextlib.contextmanager +def _credentials_lock(timeout_seconds: float = LOCK_TIMEOUT_SECONDS): + """Cross-process lock around the credentials file (fcntl POSIX / msvcrt Windows). + + Thread-safe via a per-thread re-entrancy counter — nested acquisitions by the + same thread no-op after the first. On unsupported platforms this degrades to + a threading.Lock (still safe within a single process). + """ + depth = getattr(_lock_state, "depth", 0) + if depth > 0: + _lock_state.depth = depth + 1 + try: + yield + finally: + _lock_state.depth -= 1 + return + + lock_file_path = _lock_path() + lock_file_path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(lock_file_path), os.O_CREAT | os.O_RDWR, 0o600) + acquired = False + try: + try: + import fcntl # POSIX + except ImportError: + fcntl = None # Windows + + if fcntl is not None: + deadline = time.monotonic() + max(0.0, float(timeout_seconds)) + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise TimeoutError( + f"Timed out acquiring Google OAuth credentials lock at {lock_file_path}." + ) + time.sleep(0.05) + else: + try: + import msvcrt # type: ignore[import-not-found] + + deadline = time.monotonic() + max(0.0, float(timeout_seconds)) + while True: + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + acquired = True + break + except OSError: + if time.monotonic() >= deadline: + raise TimeoutError( + f"Timed out acquiring Google OAuth credentials lock at {lock_file_path}." + ) + time.sleep(0.05) + except ImportError: + # Last resort: threading-only + logger.debug("Neither fcntl nor msvcrt available; falling back to thread-only lock") + acquired = True + + _lock_state.depth = 1 + yield + finally: + try: + if acquired: + try: + import fcntl + + fcntl.flock(fd, fcntl.LOCK_UN) + except ImportError: + try: + import msvcrt # type: ignore[import-not-found] + + try: + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + except OSError: + pass + except ImportError: + pass + finally: + os.close(fd) + _lock_state.depth = 0 + + +# ============================================================================= +# Client ID resolution +# ============================================================================= + +def _get_client_id() -> str: + return (os.getenv(ENV_CLIENT_ID) or _DEFAULT_CLIENT_ID).strip() + + +def _get_client_secret() -> str: + return (os.getenv(ENV_CLIENT_SECRET) or _DEFAULT_CLIENT_SECRET).strip() + + +def _require_client_id() -> str: + client_id = _get_client_id() + if not client_id: + raise GoogleOAuthError( + "Google OAuth client ID is not configured. Set " + f"{ENV_CLIENT_ID} in ~/.hermes/.env, or ask the Hermes maintainers to " + "ship a default. See " + "https://github.com/NousResearch/hermes-agent/blob/main/website/docs/integrations/providers.md " + "for the GCP Desktop OAuth client registration walkthrough.", + code="google_oauth_client_id_missing", + ) + return client_id + + +# ============================================================================= +# PKCE +# ============================================================================= + +def _generate_pkce_pair() -> Tuple[str, str]: + """Generate a PKCE (verifier, challenge) pair using S256. + + Verifier is 43–128 chars of url-safe base64 (we use 64 bytes → 86 chars). + Challenge is SHA256(verifier), url-safe base64 without padding. + """ + verifier = secrets.token_urlsafe(64) # 86 chars, well within PKCE limits + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return verifier, challenge + + +# ============================================================================= +# Credential I/O +# ============================================================================= + +@dataclass +class GoogleCredentials: + access_token: str + refresh_token: str + expires_at: float + client_id: str + client_secret: str = "" + email: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "client_id": self.client_id, + "client_secret": self.client_secret, + "access_token": self.access_token, + "refresh_token": self.refresh_token, + "expires_at": float(self.expires_at), + "email": self.email, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GoogleCredentials": + return cls( + access_token=str(data.get("access_token", "") or ""), + refresh_token=str(data.get("refresh_token", "") or ""), + expires_at=float(data.get("expires_at", 0) or 0), + client_id=str(data.get("client_id", "") or ""), + client_secret=str(data.get("client_secret", "") or ""), + email=str(data.get("email", "") or ""), + ) + + +def load_credentials() -> Optional[GoogleCredentials]: + """Load credentials from disk. Returns None if missing or corrupt.""" + path = _credentials_path() + if not path.exists(): + return None + try: + with _credentials_lock(): + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + except (json.JSONDecodeError, OSError, IOError) as exc: + logger.warning("Failed to read Google OAuth credentials at %s: %s", path, exc) + return None + if not isinstance(data, dict): + logger.warning("Google OAuth credentials file %s is not a JSON object", path) + return None + creds = GoogleCredentials.from_dict(data) + if not creds.access_token: + return None + return creds + + +def save_credentials(creds: GoogleCredentials) -> Path: + """Atomically write creds to disk with 0600 permissions.""" + path = _credentials_path() + path.parent.mkdir(parents=True, exist_ok=True) + + # Serialize outside the lock to minimize hold time + payload = json.dumps(creds.to_dict(), indent=2, sort_keys=True) + "\n" + + with _credentials_lock(): + tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}") + try: + with open(tmp_path, "w", encoding="utf-8") as fh: + fh.write(payload) + fh.flush() + os.fsync(fh.fileno()) + os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) + os.replace(tmp_path, path) + finally: + # Cleanup temp file if replace failed partway + try: + if tmp_path.exists(): + tmp_path.unlink() + except OSError: + pass + return path + + +def clear_credentials() -> None: + """Remove the creds file and its lock file. Idempotent.""" + path = _credentials_path() + with _credentials_lock(): + try: + path.unlink() + except FileNotFoundError: + pass + except OSError as exc: + logger.warning("Failed to remove Google OAuth credentials at %s: %s", path, exc) + + +# ============================================================================= +# Token endpoint +# ============================================================================= + +def _post_form(url: str, data: Dict[str, str], timeout: float) -> Dict[str, Any]: + """POST x-www-form-urlencoded and return parsed JSON response.""" + body = urllib.parse.urlencode(data).encode("ascii") + request = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8", errors="replace") + return json.loads(raw) + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = exc.read().decode("utf-8", errors="replace") + except Exception: + pass + raise GoogleOAuthError( + f"Google OAuth token endpoint returned HTTP {exc.code}: {detail or exc.reason}", + code="google_oauth_token_http_error", + ) from exc + except urllib.error.URLError as exc: + raise GoogleOAuthError( + f"Google OAuth token request failed: {exc}", + code="google_oauth_token_network_error", + ) from exc + + +def exchange_code( + code: str, + verifier: str, + redirect_uri: str, + *, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS, +) -> Dict[str, Any]: + """Exchange an authorization code for access + refresh tokens.""" + cid = client_id if client_id is not None else _require_client_id() + csecret = client_secret if client_secret is not None else _get_client_secret() + data = { + "grant_type": "authorization_code", + "code": code, + "code_verifier": verifier, + "client_id": cid, + "redirect_uri": redirect_uri, + } + if csecret: + data["client_secret"] = csecret + return _post_form(TOKEN_ENDPOINT, data, timeout) + + +def refresh_access_token( + refresh_token: str, + *, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS, +) -> Dict[str, Any]: + """Refresh the access token using a refresh token.""" + if not refresh_token: + raise GoogleOAuthError( + "Cannot refresh: refresh_token is empty. Re-run `hermes login --provider google-gemini-cli`.", + code="google_oauth_refresh_token_missing", + ) + cid = client_id if client_id is not None else _require_client_id() + csecret = client_secret if client_secret is not None else _get_client_secret() + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": cid, + } + if csecret: + data["client_secret"] = csecret + return _post_form(TOKEN_ENDPOINT, data, timeout) + + +def _fetch_user_email(access_token: str, timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS) -> str: + """Best-effort userinfo lookup for display. Failures return empty string.""" + try: + request = urllib.request.Request( + USERINFO_ENDPOINT + "?alt=json", + headers={"Authorization": f"Bearer {access_token}"}, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8", errors="replace") + data = json.loads(raw) + return str(data.get("email", "") or "") + except Exception as exc: + logger.debug("Userinfo fetch failed (non-fatal): %s", exc) + return "" + + +# ============================================================================= +# Local callback server +# ============================================================================= + +class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler): + """Captures the OAuth callback code/error and returns a styled HTML page.""" + + # Injected at class level before each flow; protected by the server being + # single-shot (we spin up a new HTTPServer per flow). + expected_state: str = "" + captured_code: Optional[str] = None + captured_error: Optional[str] = None + ready: Optional[threading.Event] = None + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002, N802 + # Silence default access-log chatter + logger.debug("OAuth callback: " + format, *args) + + def do_GET(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + if parsed.path != CALLBACK_PATH: + self.send_response(404) + self.end_headers() + return + + params = urllib.parse.parse_qs(parsed.query) + state = (params.get("state") or [""])[0] + error = (params.get("error") or [""])[0] + code = (params.get("code") or [""])[0] + + if state != type(self).expected_state: + type(self).captured_error = "state_mismatch" + self._respond_html(400, _ERROR_PAGE.format(message="State mismatch — aborting for safety.")) + elif error: + type(self).captured_error = error + self._respond_html(400, _ERROR_PAGE.format(message=f"Authorization denied: {error}")) + elif code: + type(self).captured_code = code + self._respond_html(200, _SUCCESS_PAGE) + else: + type(self).captured_error = "no_code" + self._respond_html(400, _ERROR_PAGE.format(message="Callback received no authorization code.")) + + if type(self).ready is not None: + type(self).ready.set() + + def _respond_html(self, status: int, body: str) -> None: + payload = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +_SUCCESS_PAGE = """ +Hermes — signed in + +

Signed in to Google.

+

You can close this tab and return to your terminal.

+""" + +_ERROR_PAGE = """ +Hermes — sign-in failed + +

Sign-in failed

{message}

+

Return to your terminal — Hermes can walk you through a manual paste fallback.

+""" + + +def _bind_callback_server(preferred_port: int = DEFAULT_REDIRECT_PORT) -> Tuple[http.server.HTTPServer, int]: + """Try to bind on the preferred port; fall back to an ephemeral port if busy. + + Returns (server, actual_port) or raises OSError if even the ephemeral bind fails. + """ + try: + server = http.server.HTTPServer((REDIRECT_HOST, preferred_port), _OAuthCallbackHandler) + return server, preferred_port + except OSError as exc: + logger.info( + "Preferred OAuth callback port %d unavailable (%s); requesting ephemeral port", + preferred_port, exc, + ) + # Ephemeral port fallback + server = http.server.HTTPServer((REDIRECT_HOST, 0), _OAuthCallbackHandler) + return server, server.server_address[1] + + +def _port_is_available(port: int) -> bool: + """Return True if ``port`` can be bound on the loopback interface.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind((REDIRECT_HOST, port)) + return True + except OSError: + return False + + +# ============================================================================= +# Main login flow +# ============================================================================= + +def start_oauth_flow( + *, + force_relogin: bool = False, + open_browser: bool = True, + callback_wait_seconds: float = CALLBACK_WAIT_SECONDS, +) -> GoogleCredentials: + """Run the interactive browser OAuth flow and persist credentials. + + Args: + force_relogin: If False and valid creds already exist, return them unchanged. + open_browser: If False, skip webbrowser.open and print the URL only. + callback_wait_seconds: Max seconds to wait for the browser callback. + + Returns: + GoogleCredentials that were just saved to disk. + """ + if not force_relogin: + existing = load_credentials() + if existing and existing.access_token: + logger.info("Google OAuth credentials already present; skipping login.") + return existing + + client_id = _require_client_id() + client_secret = _get_client_secret() + + verifier, challenge = _generate_pkce_pair() + state = secrets.token_urlsafe(16) + + server, port = _bind_callback_server(DEFAULT_REDIRECT_PORT) + redirect_uri = f"http://{REDIRECT_HOST}:{port}{CALLBACK_PATH}" + + # Reset class-level capture state on the handler + _OAuthCallbackHandler.expected_state = state + _OAuthCallbackHandler.captured_code = None + _OAuthCallbackHandler.captured_error = None + ready = threading.Event() + _OAuthCallbackHandler.ready = ready + + params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": OAUTH_SCOPES, + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + "access_type": "offline", + # prompt=consent on initial login ensures Google issues a refresh_token. + # We do NOT pass prompt=consent on refresh, so users aren't re-nagged. + "prompt": "consent", + } + auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params) + + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + print() + print("Opening your browser to sign in to Google…") + print(f"If it does not open automatically, visit:\n {auth_url}") + print() + + if open_browser: + try: + import webbrowser + + webbrowser.open(auth_url, new=1, autoraise=True) + except Exception as exc: + logger.debug("webbrowser.open failed: %s", exc) + + code: Optional[str] = None + try: + if ready.wait(timeout=callback_wait_seconds): + code = _OAuthCallbackHandler.captured_code + error = _OAuthCallbackHandler.captured_error + if error: + raise GoogleOAuthError( + f"Authorization failed: {error}", + code="google_oauth_authorization_failed", + ) + else: + logger.info("Callback server timed out — offering manual paste fallback.") + code = _prompt_paste_fallback() + finally: + try: + server.shutdown() + except Exception: + pass + try: + server.server_close() + except Exception: + pass + server_thread.join(timeout=2.0) + + if not code: + raise GoogleOAuthError( + "No authorization code received. Aborting.", + code="google_oauth_no_code", + ) + + token_resp = exchange_code( + code, verifier, redirect_uri, + client_id=client_id, client_secret=client_secret, + ) + return _persist_token_response(token_resp, client_id=client_id, client_secret=client_secret) + + +def _prompt_paste_fallback() -> Optional[str]: + """Ask the user to paste the callback URL or raw code (headless fallback).""" + print() + print("The browser callback did not complete automatically (port blocked or headless env).") + print("Paste the full redirect URL Google showed you, OR just the 'code=' parameter value.") + raw = input("Callback URL or code: ").strip() + if not raw: + return None + if raw.startswith("http://") or raw.startswith("https://"): + parsed = urllib.parse.urlparse(raw) + params = urllib.parse.parse_qs(parsed.query) + return (params.get("code") or [""])[0] or None + return raw + + +def _persist_token_response( + token_resp: Dict[str, Any], + *, + client_id: str, + client_secret: str, +) -> GoogleCredentials: + access_token = str(token_resp.get("access_token", "") or "").strip() + refresh_token = str(token_resp.get("refresh_token", "") or "").strip() + expires_in = int(token_resp.get("expires_in", 0) or 0) + if not access_token or not refresh_token: + raise GoogleOAuthError( + "Google token response missing access_token or refresh_token.", + code="google_oauth_incomplete_token_response", + ) + creds = GoogleCredentials( + access_token=access_token, + refresh_token=refresh_token, + expires_at=time.time() + max(60, expires_in), + client_id=client_id, + client_secret=client_secret, + email=_fetch_user_email(access_token), + ) + save_credentials(creds) + logger.info("Google OAuth credentials saved to %s", _credentials_path()) + return creds + + +# ============================================================================= +# Runtime token accessor (called on every inference request) +# ============================================================================= + +def get_valid_access_token(*, force_refresh: bool = False) -> str: + """Load creds, refreshing if near expiry, and return a valid bearer token. + + Raises GoogleOAuthError if no creds are stored or if refresh fails. + """ + creds = load_credentials() + if creds is None: + raise GoogleOAuthError( + "No Google OAuth credentials found. Run `hermes login --provider google-gemini-cli` first.", + code="google_oauth_not_logged_in", + ) + + should_refresh = force_refresh or (time.time() + REFRESH_SKEW_SECONDS >= creds.expires_at) + if not should_refresh: + return creds.access_token + + try: + resp = refresh_access_token( + creds.refresh_token, + client_id=creds.client_id or None, + client_secret=creds.client_secret or None, + ) + except GoogleOAuthError: + raise + + new_access = str(resp.get("access_token", "") or "").strip() + if not new_access: + raise GoogleOAuthError( + "Refresh response did not include an access_token.", + code="google_oauth_refresh_empty", + ) + # Google sometimes rotates refresh_token; preserve existing if omitted. + new_refresh = str(resp.get("refresh_token", "") or "").strip() or creds.refresh_token + expires_in = int(resp.get("expires_in", 0) or 0) + + creds.access_token = new_access + creds.refresh_token = new_refresh + creds.expires_at = time.time() + max(60, expires_in) + save_credentials(creds) + return creds.access_token + + +# ============================================================================= +# Credential-pool-compatible variant +# ============================================================================= + +def run_gemini_oauth_login_pure() -> Dict[str, Any]: + """Run the login flow and return a dict matching the credential pool shape. + + This is used by `hermes auth add --provider google-gemini-cli` to register + an entry in the multi-account credential pool alongside the flat file. + """ + creds = start_oauth_flow(force_relogin=True) + return { + "access_token": creds.access_token, + "refresh_token": creds.refresh_token, + "expires_at_ms": int(creds.expires_at * 1000), + "email": creds.email, + "client_id": creds.client_id, + } diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 556e26f97..9667e2eef 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -78,6 +78,12 @@ QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56" QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token" QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 +# Google Gemini OAuth (google-gemini-cli provider) +# Targets the OpenAI-compatible Gemini endpoint (v1beta/openai). Client id is +# sourced from HERMES_GEMINI_CLIENT_ID at runtime via agent.google_oauth. +DEFAULT_GEMINI_OAUTH_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" +GEMINI_OAUTH_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 300 # refresh 5 min before expiry + # ============================================================================= # Provider Registry @@ -122,6 +128,13 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { auth_type="oauth_external", inference_base_url=DEFAULT_QWEN_BASE_URL, ), + "google-gemini-cli": ProviderConfig( + id="google-gemini-cli", + name="Google Gemini (OAuth)", + auth_type="oauth_external", + inference_base_url=DEFAULT_GEMINI_OAUTH_BASE_URL, + base_url_env_var="HERMES_GEMINI_BASE_URL", + ), "copilot": ProviderConfig( id="copilot", name="GitHub Copilot", @@ -939,7 +952,7 @@ def resolve_provider( "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway", "opencode": "opencode-zen", "zen": "opencode-zen", - "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", + "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli", "gemini-cli": "google-gemini-cli", "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", "aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock", @@ -1251,6 +1264,88 @@ def get_qwen_auth_status() -> Dict[str, Any]: } +# ============================================================================= +# Google Gemini OAuth (google-gemini-cli) — browser-based PKCE flow. +# +# Unlike qwen-oauth / openai-codex which read tokens produced by external CLI +# tools, Hermes runs the PKCE flow itself via agent.google_oauth. Tokens live in +# ~/.hermes/auth/google_oauth.json, and runtime credential resolution refreshes +# the access token 5 min before expiry on every request. +# ============================================================================= + +def resolve_gemini_oauth_runtime_credentials( + *, + force_refresh: bool = False, +) -> Dict[str, Any]: + """Resolve runtime OAuth creds for google-gemini-cli. + + Raises AuthError if the user has not completed the OAuth login flow or if + refresh fails irrecoverably. + """ + try: + from agent.google_oauth import ( + GoogleOAuthError, + _credentials_path, + get_valid_access_token, + load_credentials, + ) + except ImportError as exc: # pragma: no cover — only hit if module is missing + raise AuthError( + f"agent.google_oauth is not importable: {exc}", + provider="google-gemini-cli", + code="google_oauth_module_missing", + ) from exc + + try: + access_token = get_valid_access_token(force_refresh=force_refresh) + except GoogleOAuthError as exc: + raise AuthError( + str(exc), + provider="google-gemini-cli", + code=exc.code, + ) from exc + + creds = load_credentials() + base_url = ( + os.getenv("HERMES_GEMINI_BASE_URL", "").strip().rstrip("/") + or DEFAULT_GEMINI_OAUTH_BASE_URL + ) + return { + "provider": "google-gemini-cli", + "base_url": base_url, + "api_key": access_token, + "source": "google-oauth", + "expires_at_ms": int((creds.expires_at if creds else 0) * 1000) or None, + "auth_file": str(_credentials_path()), + "email": (creds.email if creds else "") or "", + } + + +def get_gemini_oauth_auth_status() -> Dict[str, Any]: + """Return a status dict for `hermes auth list` / `hermes status`.""" + try: + from agent.google_oauth import _credentials_path, load_credentials + except ImportError: + return {"logged_in": False, "error": "agent.google_oauth unavailable"} + auth_path = _credentials_path() + creds = load_credentials() + if creds is None or not creds.access_token: + return { + "logged_in": False, + "auth_file": str(auth_path), + "error": "not logged in", + } + return { + "logged_in": True, + "auth_file": str(auth_path), + "source": "google-oauth", + "api_key": creds.access_token, + "expires_at_ms": int(creds.expires_at * 1000), + "email": creds.email, + } + + + # ============================================================================= # SSH / remote session detection # ============================================================================= @@ -2469,6 +2564,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: return get_codex_auth_status() if target == "qwen-oauth": return get_qwen_auth_status() + if target == "google-gemini-cli": + return get_gemini_oauth_auth_status() if target == "copilot-acp": return get_external_process_provider_status(target) # API-key providers diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 20d028200..d58a6a387 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -33,7 +33,7 @@ from hermes_constants import OPENROUTER_BASE_URL # Providers that support OAuth login in addition to API keys. -_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth"} +_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"} def _get_custom_provider_names() -> list: @@ -148,7 +148,7 @@ def auth_add_command(args) -> None: if provider.startswith(CUSTOM_POOL_PREFIX): requested_type = AUTH_TYPE_API_KEY else: - requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth"} else AUTH_TYPE_API_KEY + requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"} else AUTH_TYPE_API_KEY pool = load_pool(provider) @@ -254,6 +254,27 @@ def auth_add_command(args) -> None: print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') return + if provider == "google-gemini-cli": + from agent.google_oauth import run_gemini_oauth_login_pure + + creds = run_gemini_oauth_login_pure() + label = (getattr(args, "label", None) or "").strip() or ( + creds.get("email") or _oauth_default_label(provider, len(pool.entries()) + 1) + ) + entry = PooledCredential( + provider=provider, + id=uuid.uuid4().hex[:6], + label=label, + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source=f"{SOURCE_MANUAL}:google_pkce", + access_token=creds["access_token"], + refresh_token=creds.get("refresh_token"), + ) + pool.add_entry(entry) + print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') + return + if provider == "qwen-oauth": creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False) label = (getattr(args, "label", None) or "").strip() or label_from_token( diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7eae4d479..839b1d86b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1002,6 +1002,30 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "HERMES_GEMINI_CLIENT_ID": { + "description": "Google OAuth client ID for google-gemini-cli (required for OAuth login)", + "prompt": "Google OAuth client ID (Desktop-type)", + "url": "https://console.cloud.google.com/apis/credentials", + "password": False, + "category": "provider", + "advanced": True, + }, + "HERMES_GEMINI_CLIENT_SECRET": { + "description": "Google OAuth client secret for google-gemini-cli (optional; Desktop clients may skip)", + "prompt": "Google OAuth client secret (optional)", + "url": "https://console.cloud.google.com/apis/credentials", + "password": True, + "category": "provider", + "advanced": True, + }, + "HERMES_GEMINI_BASE_URL": { + "description": "Gemini OpenAI-compatible endpoint override (default: v1beta/openai)", + "prompt": "Gemini base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, "OPENCODE_ZEN_API_KEY": { "description": "OpenCode Zen API key (pay-as-you-go access to curated models)", "prompt": "OpenCode Zen API key", diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 70bd9d0e0..b41f7af55 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -373,7 +373,11 @@ def run_doctor(args): print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD)) try: - from hermes_cli.auth import get_nous_auth_status, get_codex_auth_status + from hermes_cli.auth import ( + get_nous_auth_status, + get_codex_auth_status, + get_gemini_oauth_auth_status, + ) nous_status = get_nous_auth_status() if nous_status.get("logged_in"): @@ -388,6 +392,14 @@ def run_doctor(args): check_warn("OpenAI Codex auth", "(not logged in)") if codex_status.get("error"): check_info(codex_status["error"]) + + gemini_status = get_gemini_oauth_auth_status() + if gemini_status.get("logged_in"): + email = gemini_status.get("email") or "" + suffix = f" as {email}" if email else "" + check_ok("Google Gemini OAuth", f"(logged in{suffix})") + else: + check_warn("Google Gemini OAuth", "(not logged in)") except Exception as e: check_warn("Auth provider status", f"(could not check: {e})") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 33d017d8c..f153bd6b8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1118,6 +1118,8 @@ def select_provider_and_model(args=None): _model_flow_openai_codex(config, current_model) elif selected_provider == "qwen-oauth": _model_flow_qwen_oauth(config, current_model) + elif selected_provider == "google-gemini-cli": + _model_flow_google_gemini_cli(config, current_model) elif selected_provider == "copilot-acp": _model_flow_copilot_acp(config, current_model) elif selected_provider == "copilot": @@ -1520,6 +1522,63 @@ def _model_flow_qwen_oauth(_config, current_model=""): print("No change.") +def _model_flow_google_gemini_cli(_config, current_model=""): + """Google Gemini OAuth (PKCE): runs the browser login, then picks a model. + + The flow: + 1. If no creds are on disk (or --force), run the PKCE flow via + ``agent.google_oauth.start_oauth_flow``. + 2. Validate the access token. + 3. Prompt for a model from the curated list. + 4. Save selection to ``~/.hermes/config.yaml``. + """ + from hermes_cli.auth import ( + DEFAULT_GEMINI_OAUTH_BASE_URL, + get_gemini_oauth_auth_status, + resolve_gemini_oauth_runtime_credentials, + _prompt_model_selection, + _save_model_choice, + _update_config_for_provider, + ) + from hermes_cli.models import _PROVIDER_MODELS + + status = get_gemini_oauth_auth_status() + if not status.get("logged_in"): + print("Not logged into Google Gemini OAuth.") + if status.get("error"): + print(f" {status['error']}") + choice = input("Run OAuth login now? [Y/n]: ").strip().lower() + if choice in {"", "y", "yes"}: + try: + from agent.google_oauth import start_oauth_flow + + start_oauth_flow(force_relogin=True) + except Exception as exc: + print(f"OAuth login failed: {exc}") + return + else: + print("Skipping login; no change.") + return + + # Verify creds resolve + try: + resolve_gemini_oauth_runtime_credentials(force_refresh=False) + except Exception as exc: + print(f"Failed to resolve Gemini OAuth credentials: {exc}") + return + + models = list(_PROVIDER_MODELS.get("google-gemini-cli") or []) + default = current_model or (models[0] if models else "gemini-2.5-flash") + selected = _prompt_model_selection(models, current_model=default) + if selected: + _save_model_choice(selected) + _update_config_for_provider("google-gemini-cli", DEFAULT_GEMINI_OAUTH_BASE_URL) + print(f"Default model set to: {selected} (via Google Gemini OAuth)") + else: + print("No change.") + + + def _model_flow_custom(config): """Custom endpoint: collect URL, API key, and model name. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 48cf6873b..e2a31c9bc 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -136,6 +136,16 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gemma-4-31b-it", "gemma-4-26b-it", ], + "google-gemini-cli": [ + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", + "gemini-3.1-flash-lite-preview", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemma-4-31b-it", + "gemma-4-26b-it", + ], "zai": [ "glm-5.1", "glm-5", @@ -534,6 +544,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"), ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"), ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"), + ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth (PKCE login; no API key needed)"), ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"), ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"), ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"), @@ -596,6 +607,9 @@ _PROVIDER_ALIASES = { "qwen": "alibaba", "alibaba-cloud": "alibaba", "qwen-portal": "qwen-oauth", + "gemini-cli": "google-gemini-cli", + "gemini-oauth": "google-gemini-cli", + "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 8b5b35fe5..e63048d71 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -64,6 +64,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { base_url_override="https://portal.qwen.ai/v1", base_url_env_var="HERMES_QWEN_BASE_URL", ), + "google-gemini-cli": HermesOverlay( + transport="openai_chat", + auth_type="oauth_external", + base_url_override="https://generativelanguage.googleapis.com/v1beta/openai", + base_url_env_var="HERMES_GEMINI_BASE_URL", + ), "copilot-acp": HermesOverlay( transport="codex_responses", auth_type="external_process", @@ -232,6 +238,13 @@ ALIASES: Dict[str, str] = { "qwen": "alibaba", "alibaba-cloud": "alibaba", + # google-gemini-cli (OAuth-auth'd Gemini via PKCE) + "gemini-cli": "google-gemini-cli", + "gemini-oauth": "google-gemini-cli", + + "google-oauth": "google-gemini-cli", + + # huggingface "hf": "huggingface", "hugging-face": "huggingface", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index ffd97a6ca..39d730792 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -22,6 +22,7 @@ from hermes_cli.auth import ( resolve_nous_runtime_credentials, resolve_codex_runtime_credentials, resolve_qwen_runtime_credentials, + resolve_gemini_oauth_runtime_credentials, resolve_api_key_provider_credentials, resolve_external_process_provider_credentials, has_usable_secret, @@ -156,6 +157,9 @@ def _resolve_runtime_from_pool_entry( elif provider == "qwen-oauth": api_mode = "chat_completions" base_url = base_url or DEFAULT_QWEN_BASE_URL + elif provider == "google-gemini-cli": + api_mode = "chat_completions" + base_url = base_url or "https://generativelanguage.googleapis.com/v1beta/openai" elif provider == "anthropic": api_mode = "anthropic_messages" cfg_provider = str(model_cfg.get("provider") or "").strip().lower() @@ -804,6 +808,25 @@ def resolve_runtime_provider( logger.info("Qwen OAuth credentials failed; " "falling through to next provider.") + if provider == "google-gemini-cli": + try: + creds = resolve_gemini_oauth_runtime_credentials() + return { + "provider": "google-gemini-cli", + "api_mode": "chat_completions", + "base_url": creds.get("base_url", "").rstrip("/"), + "api_key": creds.get("api_key", ""), + "source": creds.get("source", "google-oauth"), + "expires_at_ms": creds.get("expires_at_ms"), + "email": creds.get("email", ""), + "requested_provider": requested_provider, + } + except AuthError: + if requested_provider != "auto": + raise + logger.info("Google Gemini OAuth credentials failed; " + "falling through to next provider.") + if provider == "copilot-acp": creds = resolve_external_process_provider_credentials(provider) return { diff --git a/tests/agent/test_google_oauth.py b/tests/agent/test_google_oauth.py new file mode 100644 index 000000000..81db1f8a1 --- /dev/null +++ b/tests/agent/test_google_oauth.py @@ -0,0 +1,570 @@ +"""Tests for the Google OAuth (google-gemini-cli) provider. + +Covers: +- PKCE generation (S256 roundtrip) +- Credential save/load/clear with 0o600 permissions, atomic write +- Token exchange + refresh (success + failure) +- ``get_valid_access_token`` fresh / near-expiry / force-refresh +- Refresh-token rotation handling (preserves old when Google omits new) +- Cross-process file lock acquires and releases cleanly +- Port fallback when the preferred callback port is busy +- Manual paste fallback parses both full redirect URLs and bare codes +- Runtime provider resolution + AuthError code propagation +- get_auth_status dispatch +- _OAUTH_CAPABLE_PROVIDERS includes google-gemini-cli (and preserves existing) +- Aliases resolve to canonical slug +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import socket +import stat +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture(autouse=True) +def _isolate_env(monkeypatch, tmp_path): + """Redirect HERMES_HOME and clear Gemini env vars for every test.""" + home = tmp_path / ".hermes" + home.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + for key in ( + "HERMES_GEMINI_CLIENT_ID", + "HERMES_GEMINI_CLIENT_SECRET", + "HERMES_GEMINI_BASE_URL", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + ): + monkeypatch.delenv(key, raising=False) + return home + + +# ============================================================================= +# PKCE +# ============================================================================= + +class TestPkce: + def test_verifier_and_challenge_are_related_via_s256(self): + from agent.google_oauth import _generate_pkce_pair + + verifier, challenge = _generate_pkce_pair() + expected = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + assert challenge == expected + + def test_verifier_is_url_safe(self): + from agent.google_oauth import _generate_pkce_pair + + verifier, _ = _generate_pkce_pair() + # Per RFC 7636: url-safe base64 without padding, 43–128 chars + assert 43 <= len(verifier) <= 128 + allowed = set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + assert set(verifier).issubset(allowed) + + def test_pairs_are_unique_across_calls(self): + from agent.google_oauth import _generate_pkce_pair + + pairs = {_generate_pkce_pair()[0] for _ in range(20)} + assert len(pairs) == 20 + + +# ============================================================================= +# Credential I/O +# ============================================================================= + +class TestCredentialIo: + def _make(self): + from agent.google_oauth import GoogleCredentials + + return GoogleCredentials( + access_token="at-1", + refresh_token="rt-1", + expires_at=time.time() + 3600, + client_id="client-123", + client_secret="secret-456", + email="user@example.com", + ) + + def test_save_and_load_roundtrip(self): + from agent.google_oauth import load_credentials, save_credentials + + creds = self._make() + path = save_credentials(creds) + loaded = load_credentials() + + assert loaded is not None + assert loaded.access_token == creds.access_token + assert loaded.refresh_token == creds.refresh_token + assert loaded.email == creds.email + assert path.exists() + + def test_save_uses_0o600_permissions(self): + from agent.google_oauth import save_credentials + + creds = self._make() + path = save_credentials(creds) + mode = stat.S_IMODE(path.stat().st_mode) + assert mode == 0o600, f"expected 0o600, got {oct(mode)}" + + def test_load_returns_none_when_missing(self): + from agent.google_oauth import load_credentials + + assert load_credentials() is None + + def test_load_returns_none_on_corrupt_json(self): + from agent.google_oauth import _credentials_path, load_credentials + + path = _credentials_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("not json") + assert load_credentials() is None + + def test_load_returns_none_when_access_token_empty(self): + from agent.google_oauth import _credentials_path, load_credentials + + path = _credentials_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"access_token": "", "refresh_token": "x"})) + assert load_credentials() is None + + def test_clear_is_idempotent(self): + from agent.google_oauth import clear_credentials, save_credentials + + save_credentials(self._make()) + clear_credentials() + clear_credentials() # should not raise + + def test_atomic_write_leaves_no_tmp_file(self): + from agent.google_oauth import _credentials_path, save_credentials + + save_credentials(self._make()) + path = _credentials_path() + leftovers = list(path.parent.glob("*.tmp.*")) + assert leftovers == [] + + +# ============================================================================= +# Cross-process lock +# ============================================================================= + +class TestCrossProcessLock: + def test_lock_acquires_and_releases(self): + from agent.google_oauth import _credentials_lock, _lock_path + + with _credentials_lock(): + assert _lock_path().exists() + + # After release, a second acquisition should succeed immediately + with _credentials_lock(timeout_seconds=1.0): + pass + + def test_lock_is_reentrant_within_thread(self): + from agent.google_oauth import _credentials_lock + + with _credentials_lock(): + with _credentials_lock(): + with _credentials_lock(): + pass + + +# ============================================================================= +# Client credential resolution +# ============================================================================= + +class TestClientIdResolution: + def test_env_var_overrides_default(self, monkeypatch): + from agent.google_oauth import _get_client_id + + monkeypatch.setenv("HERMES_GEMINI_CLIENT_ID", "env-client-xyz") + assert _get_client_id() == "env-client-xyz" + + def test_missing_client_id_raises(self): + from agent.google_oauth import GoogleOAuthError, _require_client_id + + with pytest.raises(GoogleOAuthError) as exc_info: + _require_client_id() + assert exc_info.value.code == "google_oauth_client_id_missing" + + +# ============================================================================= +# Token exchange + refresh +# ============================================================================= + +class TestTokenExchange: + def test_exchange_code_sends_correct_body(self, monkeypatch): + from agent import google_oauth + + captured = {} + + def fake_post(url, data, timeout): + captured["url"] = url + captured["data"] = data + return {"access_token": "at", "refresh_token": "rt", "expires_in": 3600} + + monkeypatch.setattr(google_oauth, "_post_form", fake_post) + monkeypatch.setenv("HERMES_GEMINI_CLIENT_ID", "cid-123") + + google_oauth.exchange_code( + code="auth-code-abc", + verifier="verifier-xyz", + redirect_uri="http://127.0.0.1:8085/oauth2callback", + ) + + assert captured["data"]["grant_type"] == "authorization_code" + assert captured["data"]["code"] == "auth-code-abc" + assert captured["data"]["code_verifier"] == "verifier-xyz" + assert captured["data"]["client_id"] == "cid-123" + + def test_refresh_access_token_success(self, monkeypatch): + from agent import google_oauth + + monkeypatch.setattr( + google_oauth, "_post_form", + lambda *a, **kw: {"access_token": "new-at", "expires_in": 3600}, + ) + monkeypatch.setenv("HERMES_GEMINI_CLIENT_ID", "cid") + + resp = google_oauth.refresh_access_token("refresh-abc") + assert resp["access_token"] == "new-at" + + def test_refresh_without_refresh_token_raises(self): + from agent.google_oauth import GoogleOAuthError, refresh_access_token + + with pytest.raises(GoogleOAuthError) as exc_info: + refresh_access_token("") + assert exc_info.value.code == "google_oauth_refresh_token_missing" + + +# ============================================================================= +# get_valid_access_token +# ============================================================================= + +class TestGetValidAccessToken: + def _save(self, **overrides): + from agent.google_oauth import GoogleCredentials, save_credentials + + defaults = { + "access_token": "current-at", + "refresh_token": "rt-1", + "expires_at": time.time() + 3600, + "client_id": "cid", + "client_secret": "", + } + defaults.update(overrides) + save_credentials(GoogleCredentials(**defaults)) + + def test_returns_cached_token_when_fresh(self): + from agent.google_oauth import get_valid_access_token + + self._save(expires_at=time.time() + 3600) + token = get_valid_access_token() + assert token == "current-at" + + def test_refreshes_when_near_expiry(self, monkeypatch): + from agent import google_oauth + + self._save(expires_at=time.time() + 30) # within 5-min skew + monkeypatch.setattr( + google_oauth, "_post_form", + lambda *a, **kw: {"access_token": "refreshed-at", "expires_in": 3600}, + ) + token = google_oauth.get_valid_access_token() + assert token == "refreshed-at" + # Reloaded creds should have new access_token + loaded = google_oauth.load_credentials() + assert loaded.access_token == "refreshed-at" + + def test_force_refresh_ignores_expiry(self, monkeypatch): + from agent import google_oauth + + self._save(expires_at=time.time() + 3600) # plenty of time left + monkeypatch.setattr( + google_oauth, "_post_form", + lambda *a, **kw: {"access_token": "forced-at", "expires_in": 3600}, + ) + token = google_oauth.get_valid_access_token(force_refresh=True) + assert token == "forced-at" + + def test_raises_when_not_logged_in(self): + from agent.google_oauth import GoogleOAuthError, get_valid_access_token + + with pytest.raises(GoogleOAuthError) as exc_info: + get_valid_access_token() + assert exc_info.value.code == "google_oauth_not_logged_in" + + def test_preserves_refresh_token_when_google_omits_new_one(self, monkeypatch): + """Google sometimes omits refresh_token from refresh responses; keep the old one.""" + from agent import google_oauth + + self._save(expires_at=time.time() + 30, refresh_token="original-rt") + # Refresh response has no refresh_token field + monkeypatch.setattr( + google_oauth, "_post_form", + lambda *a, **kw: {"access_token": "new-at", "expires_in": 3600}, + ) + google_oauth.get_valid_access_token() + + loaded = google_oauth.load_credentials() + assert loaded.refresh_token == "original-rt" + + def test_rotates_refresh_token_when_google_returns_new_one(self, monkeypatch): + from agent import google_oauth + + self._save(expires_at=time.time() + 30, refresh_token="original-rt") + monkeypatch.setattr( + google_oauth, "_post_form", + lambda *a, **kw: { + "access_token": "new-at", + "refresh_token": "rotated-rt", + "expires_in": 3600, + }, + ) + google_oauth.get_valid_access_token() + + loaded = google_oauth.load_credentials() + assert loaded.refresh_token == "rotated-rt" + + +# ============================================================================= +# Callback server port fallback +# ============================================================================= + +class TestCallbackServer: + def test_binds_preferred_port_when_free(self): + from agent.google_oauth import _bind_callback_server + + # Find an unused port in the 50000-60000 range so we don't collide with + # real services even on busy dev machines. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + server, actual_port = _bind_callback_server(preferred_port=port) + try: + assert actual_port == port + finally: + server.server_close() + + def test_falls_back_to_ephemeral_when_preferred_busy(self): + from agent.google_oauth import _bind_callback_server + + # Occupy a port so binding to it fails + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as blocker: + blocker.bind(("127.0.0.1", 0)) + blocker.listen(1) + busy_port = blocker.getsockname()[1] + + server, actual_port = _bind_callback_server(preferred_port=busy_port) + try: + assert actual_port != busy_port + assert actual_port > 0 + finally: + server.server_close() + + +# ============================================================================= +# Manual paste fallback +# ============================================================================= + +class TestPasteFallback: + def test_accepts_full_redirect_url(self, monkeypatch): + from agent import google_oauth + + pasted = "http://127.0.0.1:8085/oauth2callback?code=abc123&state=xyz&scope=..." + monkeypatch.setattr("builtins.input", lambda *_: pasted) + assert google_oauth._prompt_paste_fallback() == "abc123" + + def test_accepts_bare_code(self, monkeypatch): + from agent import google_oauth + + monkeypatch.setattr("builtins.input", lambda *_: "raw-code-xyz") + assert google_oauth._prompt_paste_fallback() == "raw-code-xyz" + + def test_empty_input_returns_none(self, monkeypatch): + from agent import google_oauth + + monkeypatch.setattr("builtins.input", lambda *_: " ") + assert google_oauth._prompt_paste_fallback() is None + + +# ============================================================================= +# Runtime provider integration +# ============================================================================= + +class TestRuntimeProvider: + def test_resolves_when_valid_token_exists(self): + from agent.google_oauth import GoogleCredentials, save_credentials + from hermes_cli.auth import resolve_gemini_oauth_runtime_credentials + + save_credentials(GoogleCredentials( + access_token="live-token", + refresh_token="rt", + expires_at=time.time() + 3600, + client_id="cid", + email="u@e.com", + )) + + creds = resolve_gemini_oauth_runtime_credentials() + assert creds["provider"] == "google-gemini-cli" + assert creds["api_key"] == "live-token" + assert creds["source"] == "google-oauth" + assert "generativelanguage.googleapis.com" in creds["base_url"] + assert creds["email"] == "u@e.com" + + def test_raises_autherror_when_not_logged_in(self): + from hermes_cli.auth import AuthError, resolve_gemini_oauth_runtime_credentials + + with pytest.raises(AuthError) as exc_info: + resolve_gemini_oauth_runtime_credentials() + assert exc_info.value.code == "google_oauth_not_logged_in" + + def test_runtime_provider_dispatches_gemini(self): + from agent.google_oauth import GoogleCredentials, save_credentials + from hermes_cli.runtime_provider import resolve_runtime_provider + + save_credentials(GoogleCredentials( + access_token="tok", + refresh_token="rt", + expires_at=time.time() + 3600, + client_id="cid", + )) + + result = resolve_runtime_provider(requested="google-gemini-cli") + assert result["provider"] == "google-gemini-cli" + assert result["api_mode"] == "chat_completions" + assert result["api_key"] == "tok" + + def test_base_url_env_override(self, monkeypatch): + from agent.google_oauth import GoogleCredentials, save_credentials + from hermes_cli.auth import resolve_gemini_oauth_runtime_credentials + + monkeypatch.setenv("HERMES_GEMINI_BASE_URL", "https://custom.example/v1") + save_credentials(GoogleCredentials( + access_token="tok", refresh_token="rt", + expires_at=time.time() + 3600, client_id="cid", + )) + + creds = resolve_gemini_oauth_runtime_credentials() + assert creds["base_url"] == "https://custom.example/v1" + + +# ============================================================================= +# Provider registration touchpoints +# ============================================================================= + +class TestProviderRegistration: + def test_registry_entry_exists(self): + from hermes_cli.auth import PROVIDER_REGISTRY + + assert "google-gemini-cli" in PROVIDER_REGISTRY + pc = PROVIDER_REGISTRY["google-gemini-cli"] + assert pc.auth_type == "oauth_external" + assert "generativelanguage.googleapis.com" in pc.inference_base_url + + @pytest.mark.parametrize("alias", [ + "gemini-cli", "gemini-oauth", "google-gemini-cli", + ]) + def test_aliases_resolve(self, alias): + from hermes_cli.auth import resolve_provider + + assert resolve_provider(alias) == "google-gemini-cli" + + def test_models_catalog_populated(self): + from hermes_cli.models import _PROVIDER_MODELS, CANONICAL_PROVIDERS + + assert len(_PROVIDER_MODELS["google-gemini-cli"]) >= 5 + assert any(p.slug == "google-gemini-cli" for p in CANONICAL_PROVIDERS) + + def test_determine_api_mode_returns_chat_completions(self): + from hermes_cli.providers import determine_api_mode + + mode = determine_api_mode( + "google-gemini-cli", + base_url="https://generativelanguage.googleapis.com/v1beta/openai", + ) + assert mode == "chat_completions" + + def test_oauth_capable_set_preserves_existing_providers(self): + """PR #10779 regressed this — make sure we DIDN'T drop anthropic/nous.""" + from hermes_cli.auth_commands import _OAUTH_CAPABLE_PROVIDERS + + for required in ("anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"): + assert required in _OAUTH_CAPABLE_PROVIDERS, \ + f"{required} missing from _OAUTH_CAPABLE_PROVIDERS" + + def test_config_env_vars_registered(self): + from hermes_cli.config import OPTIONAL_ENV_VARS + + for key in ( + "HERMES_GEMINI_CLIENT_ID", + "HERMES_GEMINI_CLIENT_SECRET", + "HERMES_GEMINI_BASE_URL", + ): + assert key in OPTIONAL_ENV_VARS + + +# ============================================================================= +# Auth status dispatch +# ============================================================================= + +class TestAuthStatus: + def test_status_when_not_logged_in(self): + from hermes_cli.auth import get_auth_status + + status = get_auth_status("google-gemini-cli") + assert status["logged_in"] is False + + def test_status_when_logged_in(self): + from agent.google_oauth import GoogleCredentials, save_credentials + from hermes_cli.auth import get_auth_status + + save_credentials(GoogleCredentials( + access_token="tok", refresh_token="rt", + expires_at=time.time() + 3600, client_id="cid", + email="tek@nous.ai", + )) + + status = get_auth_status("google-gemini-cli") + assert status["logged_in"] is True + assert status["source"] == "google-oauth" + assert status["email"] == "tek@nous.ai" + + +# ============================================================================= +# run_gemini_oauth_login_pure +# ============================================================================= + +class TestOauthLoginPure: + def test_returns_pool_compatible_dict(self, monkeypatch): + from agent import google_oauth + + def fake_start(**kw): + return google_oauth.GoogleCredentials( + access_token="at", refresh_token="rt", + expires_at=time.time() + 3600, + client_id="cid", email="u@e.com", + ) + + monkeypatch.setattr(google_oauth, "start_oauth_flow", fake_start) + + result = google_oauth.run_gemini_oauth_login_pure() + assert result["access_token"] == "at" + assert result["refresh_token"] == "rt" + assert "expires_at_ms" in result + assert isinstance(result["expires_at_ms"], int) + assert result["email"] == "u@e.com" diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index c0eaf6e62..120d7c87f 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -35,12 +35,68 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) | | **Hugging Face** | `HF_TOKEN` in `~/.hermes/.env` (provider: `huggingface`, aliases: `hf`) | | **Google / Gemini** | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) in `~/.hermes/.env` (provider: `gemini`) | +| **Google Gemini (OAuth)** | `hermes model` → "Google Gemini (OAuth)" (provider: `google-gemini-cli`, browser PKCE login, requires `HERMES_GEMINI_CLIENT_ID`) | | **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) | :::tip Model key alias In the `model:` config section, you can use either `default:` or `model:` as the key name for your model ID. Both `model: { default: my-model }` and `model: { model: my-model }` work identically. ::: + +### Google Gemini via OAuth (`google-gemini-cli`) + +The `google-gemini-cli` provider lets you authenticate with your Google account +via a browser-based Authorization Code + PKCE flow — no API key copy-paste, and +credentials are refreshed automatically before every request. + +**Quick start:** + +```bash +# 1. Set your OAuth client ID (see "Registering a Desktop OAuth client" below) +export HERMES_GEMINI_CLIENT_ID="your-client-id.apps.googleusercontent.com" + +# 2. Run the login flow +hermes model +# → pick "Google Gemini (OAuth)" +# → a browser opens to accounts.google.com, sign in, Hermes captures the callback + +# 3. Chat as normal +hermes chat +``` + +**Storage:** tokens are persisted to `~/.hermes/auth/google_oauth.json` with +0600 permissions, atomic writes, and a cross-process fcntl lock so multiple +Hermes instances can safely share a session. + +**Endpoint:** requests are routed to Google's OpenAI-compatible Gemini endpoint +(`https://generativelanguage.googleapis.com/v1beta/openai`) with a Bearer +access token. Supports the full Gemini 2.5 / 3.x lineup and Gemma open models. + +#### Registering a Desktop OAuth client + +Hermes does not ship with a default OAuth client ID — you register one yourself +in Google Cloud Console so quota and consent screens are scoped to your +organization: + +1. Go to . +2. Create (or pick) a project and click **"Create Credentials" → "OAuth client ID"**. +3. Choose **Application type: Desktop app**, name it "Hermes Agent". +4. Enable the **Generative Language API** for the project under APIs & Services. +5. Download the JSON and set `HERMES_GEMINI_CLIENT_ID` in `~/.hermes/.env` + (client secret is optional for Desktop clients but can be set via + `HERMES_GEMINI_CLIENT_SECRET` if required by your org policy). + +#### Troubleshooting + +- **Port 8085 already in use** — Hermes will automatically fall back to an + ephemeral port. Add that exact URL to your OAuth client's authorized redirect + URIs if Google refuses it. +- **"State mismatch — aborting for safety"** — someone hit the callback URL + with a stale/forged request. Re-run the login. +- **Refresh failures persist** — re-run login (`hermes auth add --provider + google-gemini-cli`); stale refresh tokens can happen after password changes + or scope revocation. + :::info Codex Note The OpenAI Codex provider authenticates via device code (open a URL, enter a code). Hermes stores the resulting credentials in its own auth store under `~/.hermes/auth.json` and can import existing Codex CLI credentials from `~/.codex/auth.json` when present. No Codex CLI installation is required. ::: diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index c4d4a11fa..9c1c04fb0 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -47,6 +47,9 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `GOOGLE_API_KEY` | Google AI Studio API key ([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)) | | `GEMINI_API_KEY` | Alias for `GOOGLE_API_KEY` | | `GEMINI_BASE_URL` | Override Google AI Studio base URL | +| `HERMES_GEMINI_CLIENT_ID` | OAuth client ID for `google-gemini-cli` PKCE login (required — register a Desktop client at [console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)) | +| `HERMES_GEMINI_CLIENT_SECRET` | OAuth client secret for `google-gemini-cli` (optional for Desktop clients) | +| `HERMES_GEMINI_BASE_URL` | Override the Gemini OAuth inference endpoint (default: `https://generativelanguage.googleapis.com/v1beta/openai`) | | `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) | | `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override | | `DASHSCOPE_API_KEY` | Alibaba Cloud DashScope API key for Qwen models ([modelstudio.console.alibabacloud.com](https://modelstudio.console.alibabacloud.com/)) |