"""OAuth provider dashboard routes: catalog/status, disconnect, and in-browser device-code login flows. Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on ``web_server`` stay there and are imported lazily at call time (cycle-safe). """ import logging import asyncio import sys import time from fastapi import APIRouter from hermes_cli.web_deps import late from fastapi import HTTPException, Request from hermes_cli.web_models import OAuthSubmitBody from typing import Any, Dict, Optional import threading import os import secrets _log = logging.getLogger("hermes_cli.web_server") router = APIRouter() # web_server helpers, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative. _external_process_cli_command = late("_external_process_cli_command") _oauth_profile_name = late("_oauth_profile_name") _profile_scope = late("_profile_scope") _require_token = late("_require_token") _resolve_profile_dir = late("_resolve_profile_dir") _minimax_poller = late("_minimax_poller") _nous_poller = late("_nous_poller") _truncate_token = late("_truncate_token") _xai_device_poller = late("_xai_device_poller") def _http_response_error_detail(resp: Any) -> str: """Best-effort extraction of a short provider error detail.""" try: payload = resp.json() except Exception: payload = None if isinstance(payload, dict): error = payload.get("error") if isinstance(error, dict): parts = [ str(error.get(key, "")).strip() for key in ("message", "error_description", "code", "type") if str(error.get(key, "")).strip() ] if parts: return ": ".join(parts) if isinstance(error, str) and error.strip(): return error.strip() for key in ("detail", "message", "error_description"): value = payload.get(key) if isinstance(value, str) and value.strip(): return value.strip() text = str(getattr(resp, "text", "") or "").strip() return text[:500] def _codex_device_code_start_error(resp: Any) -> str: """Dashboard-facing OpenAI Codex device-code start failure.""" status = getattr(resp, "status_code", "unknown") detail = _http_response_error_detail(resp) lower = detail.lower() if "device" in lower and ("authori" in lower or "enable" in lower): message = ( "OpenAI rejected the device-code login request. Your OpenAI " "account may need device-code authorization enabled before Hermes " "can start this dashboard login. Enable device-code authorization " "in OpenAI, then return here and click Login again." ) else: message = ( "OpenAI rejected the device-code login request. Please try Login " "again from the dashboard after checking your OpenAI account settings." ) if detail: return f"{message} (HTTP {status}: {detail})" return f"{message} (HTTP {status})" def _new_oauth_session( provider_id: str, flow: str, profile: Optional[str] = None, ) -> tuple[str, Dict[str, Any]]: """Create + register a new OAuth session, return (session_id, session_dict).""" from hermes_cli.web_server import _oauth_sessions, _oauth_sessions_lock sid = secrets.token_urlsafe(16) profile_name = _oauth_profile_name(profile) sess = { "session_id": sid, "provider": provider_id, "flow": flow, "profile": profile_name, "created_at": time.time(), "status": "pending", # pending | approved | denied | expired | error "error_message": None, } with _oauth_sessions_lock: _oauth_sessions[sid] = sess return sid, sess def _codex_full_login_worker(session_id: str) -> None: """Run the complete OpenAI Codex device-code flow. Codex doesn't use the standard OAuth device-code endpoints; it has its own ``/api/accounts/deviceauth/usercode`` (JSON body, returns ``device_auth_id``) and ``/api/accounts/deviceauth/token`` (JSON body polled until 200). On success the response carries an ``authorization_code`` + ``code_verifier`` that get exchanged at CODEX_OAUTH_TOKEN_URL with grant_type=authorization_code. The flow is replicated inline (rather than calling _codex_device_code_login) because that helper prints/blocks/polls in a single function — we need to surface the user_code to the dashboard the moment we receive it, well before polling completes. """ from hermes_cli.web_server import _oauth_sessions, _oauth_sessions_lock try: import httpx from hermes_cli.auth import ( CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL, ) issuer = "https://auth.openai.com" # Step 1: request device code with httpx.Client(timeout=httpx.Timeout(15.0)) as client: resp = client.post( f"{issuer}/api/accounts/deviceauth/usercode", json={"client_id": CODEX_OAUTH_CLIENT_ID}, headers={"Content-Type": "application/json"}, ) if resp.status_code != 200: raise RuntimeError(_codex_device_code_start_error(resp)) device_data = resp.json() user_code = device_data.get("user_code", "") device_auth_id = device_data.get("device_auth_id", "") poll_interval = max(3, int(device_data.get("interval", "5"))) if not user_code or not device_auth_id: raise RuntimeError("device-code response missing user_code or device_auth_id") verification_url = f"{issuer}/codex/device" with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: return sess["user_code"] = user_code sess["verification_url"] = verification_url sess["device_auth_id"] = device_auth_id sess["interval"] = poll_interval sess["expires_in"] = 15 * 60 # OpenAI's effective limit sess["expires_at"] = time.time() + sess["expires_in"] # Captured now (not re-derived after cancel pops the session) so a # cancelled session can never fall back to the caller's current # profile scope at save time. session_profile = sess.get("profile") # Step 2: poll until authorized deadline = time.monotonic() + sess["expires_in"] code_resp = None with httpx.Client(timeout=httpx.Timeout(15.0)) as client: while time.monotonic() < deadline: if sess.get("cancelled"): _log.info("oauth/device: openai-codex login cancelled (session=%s)", session_id) return time.sleep(poll_interval) if sess.get("cancelled"): _log.info("oauth/device: openai-codex login cancelled (session=%s)", session_id) return poll = client.post( f"{issuer}/api/accounts/deviceauth/token", json={"device_auth_id": device_auth_id, "user_code": user_code}, headers={"Content-Type": "application/json"}, ) if poll.status_code == 200: code_resp = poll.json() break if poll.status_code in {403, 404}: continue # user hasn't authorized yet raise RuntimeError(f"deviceauth/token poll returned {poll.status_code}") if code_resp is None: with _oauth_sessions_lock: sess["status"] = "expired" sess["error_message"] = "Device code expired before approval" return if sess.get("cancelled"): _log.info("oauth/device: openai-codex login cancelled before token exchange (session=%s)", session_id) return # Step 3: exchange authorization_code for tokens authorization_code = code_resp.get("authorization_code", "") code_verifier = code_resp.get("code_verifier", "") if not authorization_code or not code_verifier: raise RuntimeError("device-auth response missing authorization_code/code_verifier") with httpx.Client(timeout=httpx.Timeout(15.0)) as client: token_resp = client.post( CODEX_OAUTH_TOKEN_URL, data={ "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": f"{issuer}/deviceauth/callback", "client_id": CODEX_OAUTH_CLIENT_ID, "code_verifier": code_verifier, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) if token_resp.status_code != 200: raise RuntimeError(f"token exchange returned {token_resp.status_code}") tokens = token_resp.json() access_token = tokens.get("access_token", "") refresh_token = tokens.get("refresh_token", "") if not access_token: raise RuntimeError("token exchange did not return access_token") from hermes_cli.auth import _save_codex_tokens # The cancellation check and the save must be one atomic critical # section under the same lock cancel_oauth_session() uses. Checking # "cancelled" and then saving as two separate steps left a window # where DELETE could flip the flag between them and the worker would # still persist tokens after the user believed the login was # aborted. Holding the lock across both closes that window: DELETE # either lands before this section (worker observes cancelled and # returns) or blocks until this section (and the save) is done. with _oauth_sessions_lock: if sess.get("cancelled"): _log.info("oauth/device: openai-codex login cancelled before token save (session=%s)", session_id) return with _profile_scope(session_profile): _save_codex_tokens({ "access_token": access_token, "refresh_token": refresh_token, }) sess["status"] = "approved" _log.info("oauth/device: openai-codex login completed (session=%s)", session_id) except Exception as e: _log.warning("codex device-code worker failed (session=%s): %s", session_id, e) with _oauth_sessions_lock: s = _oauth_sessions.get(session_id) if s: s["status"] = "error" s["error_message"] = str(e) def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]: """Dispatch to the right status helper for an OAuth provider entry.""" if status_fn is not None: try: return status_fn() except Exception as e: return {"logged_in": False, "error": str(e)} try: from hermes_cli import auth as hauth if provider_id == "nous": # Read-only accounts-tab card: refresh-free snapshot so listing # providers never performs an OAuth refresh. raw = hauth.get_nous_auth_status_local() return { "logged_in": bool(raw.get("logged_in")), "source": "nous_portal", "source_label": raw.get("portal_base_url") or "Nous Portal", "token_preview": _truncate_token(raw.get("access_token")), "expires_at": raw.get("access_expires_at"), "has_refresh_token": bool(raw.get("has_refresh_token")), } if provider_id == "openai-codex": raw = hauth.get_codex_auth_status() return { "logged_in": bool(raw.get("logged_in")), "source": raw.get("source") or "openai_codex", "source_label": raw.get("auth_mode") or "OpenAI Codex", "token_preview": _truncate_token(raw.get("api_key")), "expires_at": None, "has_refresh_token": False, "last_refresh": raw.get("last_refresh"), } if provider_id == "qwen-oauth": raw = hauth.get_qwen_auth_status() return { "logged_in": bool(raw.get("logged_in")), "source": "qwen_cli", "source_label": raw.get("auth_store_path") or "Qwen CLI", "token_preview": _truncate_token(raw.get("access_token")), "expires_at": raw.get("expires_at"), "has_refresh_token": bool(raw.get("has_refresh_token")), } if provider_id == "minimax-oauth": raw = hauth.get_minimax_oauth_auth_status() return { "logged_in": bool(raw.get("logged_in")), "source": "minimax_oauth", "source_label": f"MiniMax ({raw.get('region', 'global')})", "token_preview": None, "expires_at": raw.get("expires_at"), "has_refresh_token": True, } if provider_id == "xai-oauth": raw = hauth.get_xai_oauth_auth_status() # source_label is meant to be a human-readable origin (auth-store # path / credential source), not the internal auth_mode string # ("oauth_pkce"). Prefer the store path, then the source slug. return { "logged_in": bool(raw.get("logged_in")), "source": raw.get("source") or "xai_oauth", "source_label": raw.get("auth_store") or raw.get("source") or "xAI Grok OAuth", "token_preview": _truncate_token(raw.get("api_key")), "expires_at": None, "has_refresh_token": True, "last_refresh": raw.get("last_refresh"), } # No hand-written branch for this provider id: fall through to the # canonical slug-driven dispatcher so accounts-tab providers derived # from the unified catalog (which carry status_fn=None) still reflect # real login state instead of rendering permanently logged-out. This # closes the membership-auto-extends-but-status-doesn't gap: add an # OAuth/account provider plugin and its card shows the right state. raw = hauth.get_auth_status(provider_id) if isinstance(raw, dict) and "logged_in" in raw: return { "logged_in": bool(raw.get("logged_in")), "source": raw.get("source") or raw.get("provider") or provider_id, "source_label": ( raw.get("source_label") or raw.get("auth_store") or raw.get("auth_store_path") or raw.get("base_url") or raw.get("name") or "" ), "token_preview": _truncate_token( raw.get("access_token") or raw.get("api_key") ), "expires_at": raw.get("expires_at") or raw.get("access_expires_at"), "has_refresh_token": bool(raw.get("has_refresh_token")), } except Exception as e: return {"logged_in": False, "error": str(e)} return {"logged_in": False} async def _start_device_code_flow( provider_id: str, profile: Optional[str] = None, ) -> Dict[str, Any]: """Initiate a device-code flow (Nous, OpenAI Codex, MiniMax, or xAI). Calls the provider's device-auth endpoint via the existing CLI helpers, then spawns a background poller. Returns the user-facing display fields so the UI can render the verification page link + user code. """ from hermes_cli.web_server import _oauth_sessions, _oauth_sessions_lock if provider_id == "nous": from hermes_cli.auth import ( _request_device_code, PROVIDER_REGISTRY, ) import httpx pconfig = PROVIDER_REGISTRY["nous"] portal_base_url = ( os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL") or pconfig.portal_base_url ).rstrip("/") client_id = pconfig.client_id scope = pconfig.scope def _do_nous_device_request(): with httpx.Client( timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, ) as client: return ( _request_device_code( client=client, portal_base_url=portal_base_url, client_id=client_id, scope=scope, ), scope, ) device_data, effective_scope = await asyncio.get_running_loop().run_in_executor( None, _do_nous_device_request ) sid, sess = _new_oauth_session("nous", "device_code", profile=profile) sess["device_code"] = str(device_data["device_code"]) sess["interval"] = int(device_data["interval"]) sess["expires_at"] = time.time() + int(device_data["expires_in"]) sess["portal_base_url"] = portal_base_url sess["client_id"] = client_id sess["scope"] = effective_scope threading.Thread( target=_nous_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}" ).start() return { "session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]), "verification_url": str(device_data["verification_uri_complete"]), "expires_in": int(device_data["expires_in"]), "poll_interval": int(device_data["interval"]), } if provider_id == "openai-codex": # Codex uses fixed OpenAI device-auth endpoints; reuse the helper. sid, _ = _new_oauth_session("openai-codex", "device_code", profile=profile) # Use the helper but in a thread because it polls inline. # We can't extract just the start step without refactoring auth.py, # so we run the full helper in a worker and proxy the user_code + # verification_url back via the session dict. The helper prints # to stdout — we capture nothing here, just status. threading.Thread( target=_codex_full_login_worker, args=(sid,), daemon=True, name=f"oauth-codex-{sid[:6]}", ).start() # Block briefly until the worker has populated the user_code, OR error. deadline = time.monotonic() + 10 while time.monotonic() < deadline: with _oauth_sessions_lock: s = _oauth_sessions.get(sid) if s and (s.get("user_code") or s["status"] != "pending"): break await asyncio.sleep(0.1) with _oauth_sessions_lock: s = _oauth_sessions.get(sid, {}) if s.get("status") == "error": raise HTTPException(status_code=500, detail=s.get("error_message") or "device-auth failed") if not s.get("user_code"): raise HTTPException(status_code=504, detail="device-auth timed out before returning a user code") return { "session_id": sid, "flow": "device_code", "user_code": s["user_code"], "verification_url": s["verification_url"], "expires_in": int(s.get("expires_in") or 900), "poll_interval": int(s.get("interval") or 5), } if provider_id == "minimax-oauth": # MiniMax uses a device-code-style flow (verification URI + user # code + background poll) with a PKCE extension on top. From the # operator's perspective it's identical to Nous's device-code # flow; the PKCE bit (verifier + challenge from # _minimax_pkce_pair) is a security extension that binds the # token exchange to the original session. from hermes_cli.auth import ( _minimax_pkce_pair, _minimax_request_user_code, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_OAUTH_GLOBAL_BASE, ) import httpx verifier, challenge, state = _minimax_pkce_pair() portal_base_url = ( os.getenv("MINIMAX_PORTAL_BASE_URL") or MINIMAX_OAUTH_GLOBAL_BASE ).rstrip("/") def _do_minimax_request(): with httpx.Client( timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, follow_redirects=True, ) as client: return _minimax_request_user_code( client=client, portal_base_url=portal_base_url, client_id=MINIMAX_OAUTH_CLIENT_ID, code_challenge=challenge, state=state, ) device_data = await asyncio.get_event_loop().run_in_executor( None, _do_minimax_request ) sid, sess = _new_oauth_session("minimax-oauth", "device_code", profile=profile) # The CLI flow names this `interval_ms` because MiniMax's # `interval` field is in milliseconds (defensive default 2000ms # in _minimax_poll_token). interval_raw = device_data.get("interval") sess["interval_ms"] = ( int(interval_raw) if interval_raw is not None else None ) sess["user_code"] = str(device_data["user_code"]) sess["code_verifier"] = verifier sess["state"] = state sess["portal_base_url"] = portal_base_url sess["client_id"] = MINIMAX_OAUTH_CLIENT_ID sess["region"] = "global" # `expired_in` from MiniMax is overloaded — could be a unix-ms # timestamp OR a seconds-from-now duration. Mirror the heuristic # in _minimax_poll_token. Stash the raw value for the poller; # compute a derived expires_at + UI-friendly expires_in seconds. expired_in_raw = int(device_data["expired_in"]) sess["expired_in_raw"] = expired_in_raw if expired_in_raw > 1_000_000_000_000: # likely unix-ms expires_at_ts = expired_in_raw / 1000.0 expires_in_seconds = max(0, int(expires_at_ts - time.time())) else: expires_at_ts = time.time() + expired_in_raw expires_in_seconds = expired_in_raw sess["expires_at"] = expires_at_ts threading.Thread( target=_minimax_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}", ).start() return { "session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]), "verification_url": str(device_data["verification_uri"]), "expires_in": expires_in_seconds, "poll_interval": max(2, (sess["interval_ms"] or 2000) // 1000), } if provider_id == "xai-oauth": from hermes_cli.auth import _xai_oauth_request_device_code import httpx def _do_xai_device_request(): with httpx.Client( timeout=httpx.Timeout(20.0), headers={"Accept": "application/json"}, ) as client: return _xai_oauth_request_device_code(client) device_data = await asyncio.get_running_loop().run_in_executor( None, _do_xai_device_request ) sid, sess = _new_oauth_session("xai-oauth", "device_code", profile=profile) sess["device_code"] = str(device_data["device_code"]) sess["interval"] = int(device_data["interval"]) sess["expires_at"] = time.time() + int(device_data["expires_in"]) threading.Thread( target=_xai_device_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}", ).start() return { "session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]), "verification_url": str( device_data.get("verification_uri_complete") or device_data["verification_uri"] ), "expires_in": int(device_data["expires_in"]), "poll_interval": int(device_data["interval"]), } raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow") def _oauth_provider_disconnect_command(provider: Dict[str, Any]) -> Optional[str]: """Shell command that clears an external provider's credentials. External providers store their credentials outside Hermes, so the disconnect API deliberately refuses them (we never delete files another CLI owns on the user's behalf via a silent API call). For the ones we know how to clear we instead hand the GUI a command it can *run in the embedded terminal* — the user sees exactly what executes, and Hermes then stops resolving the token. Claude Code has no scriptable logout (only the interactive ``/logout``), so we remove the credential the same way logout does: the macOS Keychain entry (``Claude Code-credentials``) and/or the ``~/.claude/.credentials.json`` file — the two sources ``read_claude_code_credentials()`` consults. Returns None for providers we can't safely clear (the GUI shows a manual hint). """ if provider.get("flow") != "external": return None if provider.get("id") == "claude-code": rm_file = "rm -f ~/.claude/.credentials.json" if sys.platform == "darwin": return f'security delete-generic-password -s "Claude Code-credentials" 2>/dev/null; {rm_file}' return rm_file return None def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, Any]) -> Optional[str]: """Return the manual disconnect path when the API cannot clear this provider.""" # "anthropic" is flow == "external" (no in-dashboard OAuth login, see the # catalog entry) but, unlike other external providers, Hermes still OWNS # the credential it can show here: the Hermes-managed PKCE file # (~/.hermes/.anthropic_oauth.json) and its credential-pool entry, both # written by `hermes auth add anthropic` in the terminal. Those are ours # to clear via the API, so this provider is excluded from the generic # "external providers can't be auto-disconnected" rule below. if provider.get("flow") == "external" and provider.get("id") != "anthropic": if _oauth_provider_disconnect_command(provider): # The GUI offers a one-click "run in terminal" path; this hint is the # fallback wording for surfaces that only show text. return "Managed outside Hermes — run the disconnect command to remove it." return "Managed by that provider's CLI; remove it there." if status.get("source") == "env_var": return "Remove the API key from Settings → Keys instead." return None def _build_oauth_catalog() -> list[Dict[str, Any]]: """Build the Accounts-tab provider list. MEMBERSHIP is the union of: 1. ``_OAUTH_PROVIDER_CATALOG`` — the explicit, hand-tuned cards that carry bespoke flow / status_fn / cli_command (including the api-key Anthropic PKCE card and the synthetic claude-code subscription row, which are not catalog providers), and 2. every accounts-tab provider in the unified ``provider_catalog()`` (the ``hermes model`` universe) — so any OAuth/external provider added as a plugin appears automatically, with sensible defaults, even if no explicit card was written for it. The explicit catalog wins on metadata; the unified catalog guarantees we never silently drop a provider the CLI picker offers. Order: explicit cards first (their curated order), then any catalog-only providers appended in ``hermes model`` order. """ from hermes_cli.web_server import _OAUTH_PROVIDER_CATALOG rows: list[Dict[str, Any]] = [] seen: set[str] = set() # 1. Explicit hand-tuned cards (authoritative metadata + curated order). for entry in _OAUTH_PROVIDER_CATALOG: if entry["id"] in seen: continue seen.add(entry["id"]) rows.append(dict(entry)) # 2. Catalog accounts-providers not already covered — keeps the Accounts tab # in lockstep with the `hermes model` universe (zero-edit for new plugins). try: from hermes_cli.provider_catalog import provider_catalog for d in provider_catalog(): if d.tab != "accounts" or d.slug in seen: continue seen.add(d.slug) rows.append({ "id": d.slug, "name": d.label, "flow": "external", "cli_command": f"hermes auth add {d.slug}", "docs_url": d.signup_url or "", "status_fn": None, }) except Exception: pass return rows @router.get("/api/providers/oauth") async def list_oauth_providers(profile: Optional[str] = None): """Enumerate every OAuth-capable LLM provider with current status. Response shape (per provider): id stable identifier (used in DELETE path) name human label flow "device_code" | "external" cli_command fallback CLI command for users to run manually disconnect_command shell command that clears an external provider's creds (run in the embedded terminal), else null docs_url external docs/portal link for the "Learn more" link status: logged_in bool — currently has usable creds source short slug ("hermes_pkce", "claude_code", ...) source_label human-readable origin (file path, env var name) token_preview last N chars of the token, never the full token expires_at ISO timestamp string or null has_refresh_token bool Membership is derived from the unified provider_catalog() so this stays in sync with the `hermes model` picker; _OAUTH_OVERRIDES supplies per-provider flow/status/cli metadata. """ def _run(): with _profile_scope(profile): providers = [] for p in _build_oauth_catalog(): status = _resolve_provider_status(p["id"], p.get("status_fn")) disconnect_hint = _oauth_provider_disconnect_hint(p, status) providers.append({ "id": p["id"], "name": p["name"], "flow": p["flow"], "cli_command": _external_process_cli_command(p["id"], p["cli_command"]), "docs_url": p["docs_url"], "disconnect_hint": disconnect_hint, "disconnect_command": _oauth_provider_disconnect_command(p), "disconnectable": disconnect_hint is None, "status": status, }) return {"providers": providers} return await asyncio.to_thread(_run) @router.delete("/api/providers/oauth/{provider_id}") async def disconnect_oauth_provider( provider_id: str, request: Request, profile: Optional[str] = None, ): """Disconnect an OAuth provider. Token-protected (matches /env/reveal).""" _require_token(request) def _run(): with _profile_scope(profile): catalog_by_id = {p["id"]: p for p in _build_oauth_catalog()} provider = catalog_by_id.get(provider_id) if provider is None: raise HTTPException( status_code=400, detail=f"Unknown provider: {provider_id}. " f"Available: {', '.join(sorted(catalog_by_id))}", ) disconnect_hint = _oauth_provider_disconnect_hint(provider, {}) if disconnect_hint: raise HTTPException( status_code=400, detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", ) status = _resolve_provider_status(provider_id, provider.get("status_fn")) disconnect_hint = _oauth_provider_disconnect_hint(provider, status) if disconnect_hint: raise HTTPException( status_code=400, detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", ) # Anthropic clears only the Hermes-managed PKCE file and auth-store entry. # The separate claude-code catalog row is external/read-only and rejected # above so we never pretend to remove ~/.claude/* credentials owned by the CLI. if provider_id == "anthropic": cleared = False try: from agent.anthropic_adapter import _get_hermes_oauth_file oauth_file = _get_hermes_oauth_file() if oauth_file.exists(): oauth_file.unlink() cleared = True except Exception: pass # Also clear the credential pool entry if present. try: from hermes_cli.auth import clear_provider_auth cleared = clear_provider_auth("anthropic") or cleared except Exception: pass _log.info("oauth/disconnect: %s", provider_id) return {"ok": bool(cleared), "provider": provider_id} try: from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache cleared = clear_provider_auth(provider_id) if provider_id == "nous": invalidate_nous_auth_status_cache() _log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared) return {"ok": bool(cleared), "provider": provider_id} except Exception as e: _log.exception("disconnect %s failed", provider_id) raise HTTPException(status_code=500, detail=str(e)) return await asyncio.to_thread(_run) # --------------------------------------------------------------------------- # OAuth Phase 2 — in-browser device-code flows # --------------------------------------------------------------------------- # # Anthropic previously had a dashboard-triggered PKCE flow here too (server # generates a claude.ai/oauth/authorize URL, exchanges the code for tokens at # the Anthropic token endpoint, persists them). It was removed: an unattended # HTTP endpoint minting Claude Pro/Max subscription tokens outside Anthropic's # own client sits on the wrong side of Anthropic's usage policies for OAuth # credentials. The "anthropic" catalog entry is now flow == "external" and # points at `hermes auth add anthropic` (terminal PKCE, unaffected) instead. # # Device code (Nous, OpenAI Codex): # 1. POST /api/providers/oauth/{nous|openai-codex}/start # → server hits provider's device-auth endpoint # → gets { user_code, verification_url, device_code, interval, expires_in } # → spawns background poller thread that polls the token endpoint # every `interval` seconds until approved/expired # → stores poll status in _oauth_sessions[session_id] # → returns { session_id, flow: "device_code", user_code, # verification_url, expires_in, poll_interval } # 2. UI opens verification_url in a new tab and shows user_code. # 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id} # every 2s until status != "pending". # 4. On "approved" the background thread has already saved creds; UI # refreshes the providers list. # # Sessions are kept in-memory only (single-process FastAPI) and time out # after 15 minutes. A periodic cleanup runs on each /start call to GC # expired sessions so the dict doesn't grow without bound. _OAUTH_SESSION_TTL_SECONDS = 15 * 60 def _gc_oauth_sessions() -> None: """Drop expired sessions. Called opportunistically on /start.""" from hermes_cli.web_server import _oauth_sessions, _oauth_sessions_lock cutoff = time.time() - _OAUTH_SESSION_TTL_SECONDS with _oauth_sessions_lock: stale = [sid for sid, sess in _oauth_sessions.items() if sess["created_at"] < cutoff] for sid in stale: _oauth_sessions.pop(sid, None) def _validate_oauth_profile(profile: Optional[str]) -> None: profile_name = _oauth_profile_name(profile) if profile_name: _resolve_profile_dir(profile_name) @router.post("/api/providers/oauth/{provider_id}/start") async def start_oauth_login( provider_id: str, request: Request, profile: Optional[str] = None, ): """Initiate an OAuth login flow. Token-protected.""" from hermes_cli.web_server import _OAUTH_PROVIDER_CATALOG _require_token(request) _gc_oauth_sessions() _validate_oauth_profile(profile) valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} if provider_id not in valid: raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}") catalog_entry = next(p for p in _OAUTH_PROVIDER_CATALOG if p["id"] == provider_id) if catalog_entry["flow"] == "external": raise HTTPException( status_code=400, detail=f"{provider_id} uses an external CLI; run `{catalog_entry['cli_command']}` manually", ) try: if catalog_entry["flow"] == "device_code": return await _start_device_code_flow(provider_id, profile=profile) except HTTPException: raise except Exception as e: _log.exception("oauth/start %s failed", provider_id) raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=400, detail="Unsupported flow") @router.post("/api/providers/oauth/{provider_id}/submit") async def submit_oauth_code( provider_id: str, body: OAuthSubmitBody, request: Request, profile: Optional[str] = None, ): """Submit the auth code for PKCE flows. Token-protected.""" _require_token(request) raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}") @router.get("/api/providers/oauth/{provider_id}/poll/{session_id}") async def poll_oauth_session( provider_id: str, session_id: str, profile: Optional[str] = None, ): """Poll a session's status (no auth — read-only state). Shared by the device-code flows (Nous, OpenAI Codex, MiniMax, xAI). Each surfaces progress through the same background-worker-updated ``status`` field, so a single poll endpoint serves them all. """ from hermes_cli.web_server import _oauth_sessions, _oauth_sessions_lock _validate_oauth_profile(profile) requested_profile = _oauth_profile_name(profile) with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: raise HTTPException(status_code=404, detail="Session not found or expired") if sess["provider"] != provider_id: raise HTTPException(status_code=400, detail="Provider mismatch for session") if sess.get("profile") != requested_profile: raise HTTPException(status_code=400, detail="OAuth session profile mismatch") return { "session_id": session_id, "status": sess["status"], "error_message": sess.get("error_message"), "expires_at": sess.get("expires_at"), } @router.delete("/api/providers/oauth/sessions/{session_id}") async def cancel_oauth_session( session_id: str, request: Request, profile: Optional[str] = None, ): """Cancel a pending OAuth session. Token-protected. Marks the session dict ``cancelled`` before popping it so any background worker still holding a reference to that same dict (e.g. the Codex device-code poller) observes the cancellation and stops polling/exchanging/saving instead of completing the login after the user believed it was aborted. """ from hermes_cli.web_server import _oauth_sessions, _oauth_sessions_lock _require_token(request) _validate_oauth_profile(profile) requested_profile = _oauth_profile_name(profile) with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if sess is not None: if sess.get("profile") != requested_profile: raise HTTPException(status_code=400, detail="OAuth session profile mismatch") sess["cancelled"] = True _oauth_sessions.pop(session_id, None) if sess is None: return {"ok": False, "message": "session not found"} return {"ok": True, "session_id": session_id}