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_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
|
||||||
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
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
|
# Provider Registry
|
||||||
|
|
@ -122,6 +128,13 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||||
auth_type="oauth_external",
|
auth_type="oauth_external",
|
||||||
inference_base_url=DEFAULT_QWEN_BASE_URL,
|
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(
|
"copilot": ProviderConfig(
|
||||||
id="copilot",
|
id="copilot",
|
||||||
name="GitHub Copilot",
|
name="GitHub Copilot",
|
||||||
|
|
@ -939,7 +952,7 @@ def resolve_provider(
|
||||||
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
|
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
|
||||||
"aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
|
"aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
|
||||||
"opencode": "opencode-zen", "zen": "opencode-zen",
|
"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",
|
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||||||
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
|
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
|
||||||
"aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
|
"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
|
# 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()
|
return get_codex_auth_status()
|
||||||
if target == "qwen-oauth":
|
if target == "qwen-oauth":
|
||||||
return get_qwen_auth_status()
|
return get_qwen_auth_status()
|
||||||
|
if target == "google-gemini-cli":
|
||||||
|
return get_gemini_oauth_auth_status()
|
||||||
if target == "copilot-acp":
|
if target == "copilot-acp":
|
||||||
return get_external_process_provider_status(target)
|
return get_external_process_provider_status(target)
|
||||||
# API-key providers
|
# API-key providers
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ from hermes_constants import OPENROUTER_BASE_URL
|
||||||
|
|
||||||
|
|
||||||
# Providers that support OAuth login in addition to API keys.
|
# 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:
|
def _get_custom_provider_names() -> list:
|
||||||
|
|
@ -148,7 +148,7 @@ def auth_add_command(args) -> None:
|
||||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||||
requested_type = AUTH_TYPE_API_KEY
|
requested_type = AUTH_TYPE_API_KEY
|
||||||
else:
|
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)
|
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}"')
|
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||||
return
|
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":
|
if provider == "qwen-oauth":
|
||||||
creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False)
|
creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False)
|
||||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||||
|
|
|
||||||
|
|
@ -1002,6 +1002,30 @@ OPTIONAL_ENV_VARS = {
|
||||||
"category": "provider",
|
"category": "provider",
|
||||||
"advanced": True,
|
"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": {
|
"OPENCODE_ZEN_API_KEY": {
|
||||||
"description": "OpenCode Zen API key (pay-as-you-go access to curated models)",
|
"description": "OpenCode Zen API key (pay-as-you-go access to curated models)",
|
||||||
"prompt": "OpenCode Zen API key",
|
"prompt": "OpenCode Zen API key",
|
||||||
|
|
|
||||||
|
|
@ -373,7 +373,11 @@ def run_doctor(args):
|
||||||
print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD))
|
print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD))
|
||||||
|
|
||||||
try:
|
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()
|
nous_status = get_nous_auth_status()
|
||||||
if nous_status.get("logged_in"):
|
if nous_status.get("logged_in"):
|
||||||
|
|
@ -388,6 +392,14 @@ def run_doctor(args):
|
||||||
check_warn("OpenAI Codex auth", "(not logged in)")
|
check_warn("OpenAI Codex auth", "(not logged in)")
|
||||||
if codex_status.get("error"):
|
if codex_status.get("error"):
|
||||||
check_info(codex_status["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:
|
except Exception as e:
|
||||||
check_warn("Auth provider status", f"(could not check: {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)
|
_model_flow_openai_codex(config, current_model)
|
||||||
elif selected_provider == "qwen-oauth":
|
elif selected_provider == "qwen-oauth":
|
||||||
_model_flow_qwen_oauth(config, current_model)
|
_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":
|
elif selected_provider == "copilot-acp":
|
||||||
_model_flow_copilot_acp(config, current_model)
|
_model_flow_copilot_acp(config, current_model)
|
||||||
elif selected_provider == "copilot":
|
elif selected_provider == "copilot":
|
||||||
|
|
@ -1520,6 +1522,63 @@ def _model_flow_qwen_oauth(_config, current_model=""):
|
||||||
print("No change.")
|
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):
|
def _model_flow_custom(config):
|
||||||
"""Custom endpoint: collect URL, API key, and model name.
|
"""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-31b-it",
|
||||||
"gemma-4-26b-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": [
|
"zai": [
|
||||||
"glm-5.1",
|
"glm-5.1",
|
||||||
"glm-5",
|
"glm-5",
|
||||||
|
|
@ -534,6 +544,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||||
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
|
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("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"),
|
||||||
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"),
|
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("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"),
|
||||||
ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"),
|
ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"),
|
||||||
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"),
|
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"),
|
||||||
|
|
@ -596,6 +607,9 @@ _PROVIDER_ALIASES = {
|
||||||
"qwen": "alibaba",
|
"qwen": "alibaba",
|
||||||
"alibaba-cloud": "alibaba",
|
"alibaba-cloud": "alibaba",
|
||||||
"qwen-portal": "qwen-oauth",
|
"qwen-portal": "qwen-oauth",
|
||||||
|
"gemini-cli": "google-gemini-cli",
|
||||||
|
"gemini-oauth": "google-gemini-cli",
|
||||||
|
|
||||||
"hf": "huggingface",
|
"hf": "huggingface",
|
||||||
"hugging-face": "huggingface",
|
"hugging-face": "huggingface",
|
||||||
"huggingface-hub": "huggingface",
|
"huggingface-hub": "huggingface",
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||||
base_url_override="https://portal.qwen.ai/v1",
|
base_url_override="https://portal.qwen.ai/v1",
|
||||||
base_url_env_var="HERMES_QWEN_BASE_URL",
|
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(
|
"copilot-acp": HermesOverlay(
|
||||||
transport="codex_responses",
|
transport="codex_responses",
|
||||||
auth_type="external_process",
|
auth_type="external_process",
|
||||||
|
|
@ -232,6 +238,13 @@ ALIASES: Dict[str, str] = {
|
||||||
"qwen": "alibaba",
|
"qwen": "alibaba",
|
||||||
"alibaba-cloud": "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
|
# huggingface
|
||||||
"hf": "huggingface",
|
"hf": "huggingface",
|
||||||
"hugging-face": "huggingface",
|
"hugging-face": "huggingface",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from hermes_cli.auth import (
|
||||||
resolve_nous_runtime_credentials,
|
resolve_nous_runtime_credentials,
|
||||||
resolve_codex_runtime_credentials,
|
resolve_codex_runtime_credentials,
|
||||||
resolve_qwen_runtime_credentials,
|
resolve_qwen_runtime_credentials,
|
||||||
|
resolve_gemini_oauth_runtime_credentials,
|
||||||
resolve_api_key_provider_credentials,
|
resolve_api_key_provider_credentials,
|
||||||
resolve_external_process_provider_credentials,
|
resolve_external_process_provider_credentials,
|
||||||
has_usable_secret,
|
has_usable_secret,
|
||||||
|
|
@ -156,6 +157,9 @@ def _resolve_runtime_from_pool_entry(
|
||||||
elif provider == "qwen-oauth":
|
elif provider == "qwen-oauth":
|
||||||
api_mode = "chat_completions"
|
api_mode = "chat_completions"
|
||||||
base_url = base_url or DEFAULT_QWEN_BASE_URL
|
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":
|
elif provider == "anthropic":
|
||||||
api_mode = "anthropic_messages"
|
api_mode = "anthropic_messages"
|
||||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||||
|
|
@ -804,6 +808,25 @@ def resolve_runtime_provider(
|
||||||
logger.info("Qwen OAuth credentials failed; "
|
logger.info("Qwen OAuth credentials failed; "
|
||||||
"falling through to next provider.")
|
"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":
|
if provider == "copilot-acp":
|
||||||
creds = resolve_external_process_provider_credentials(provider)
|
creds = resolve_external_process_provider_credentials(provider)
|
||||||
return {
|
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`) |
|
| **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) |
|
||||||
| **Hugging Face** | `HF_TOKEN` in `~/.hermes/.env` (provider: `huggingface`, aliases: `hf`) |
|
| **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** | `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`) |
|
| **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) |
|
||||||
|
|
||||||
:::tip Model key alias
|
:::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.
|
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
|
:::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.
|
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)) |
|
| `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_API_KEY` | Alias for `GOOGLE_API_KEY` |
|
||||||
| `GEMINI_BASE_URL` | Override Google AI Studio base URL |
|
| `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_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) |
|
||||||
| `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override |
|
| `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/)) |
|
| `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