mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(gemini): add Google Gemini (OAuth) inference provider
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).
This commit is contained in:
parent
387aa9afc9
commit
1e5ee33f68
12 changed files with 1693 additions and 4 deletions
797
agent/google_oauth.py
Normal file
797
agent/google_oauth.py
Normal file
|
|
@ -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 = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Hermes — signed in</title>
|
||||
<style>
|
||||
body { font: 16px/1.5 system-ui, sans-serif; margin: 10vh auto; max-width: 32rem; text-align: center; color: #222; }
|
||||
h1 { color: #1a7f37; } p { color: #555; }
|
||||
</style></head>
|
||||
<body><h1>Signed in to Google.</h1>
|
||||
<p>You can close this tab and return to your terminal.</p></body></html>
|
||||
"""
|
||||
|
||||
_ERROR_PAGE = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Hermes — sign-in failed</title>
|
||||
<style>
|
||||
body {{ font: 16px/1.5 system-ui, sans-serif; margin: 10vh auto; max-width: 32rem; text-align: center; color: #222; }}
|
||||
h1 {{ color: #b42318; }} p {{ color: #555; }}
|
||||
</style></head>
|
||||
<body><h1>Sign-in failed</h1><p>{message}</p>
|
||||
<p>Return to your terminal — Hermes can walk you through a manual paste fallback.</p></body></html>
|
||||
"""
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
570
tests/agent/test_google_oauth.py
Normal file
570
tests/agent/test_google_oauth.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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 <https://console.cloud.google.com/apis/credentials>.
|
||||
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.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -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/)) |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue