diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index bd77798ca4..7fa47acc9f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9,11 +9,16 @@ Usage: python -m hermes_cli.main web --port 8080 """ +import asyncio +import json import logging import os import secrets import sys +import threading import time +import urllib.parse +import urllib.request from pathlib import Path from typing import Any, Dict, List, Optional @@ -334,19 +339,20 @@ async def get_status(): @app.get("/api/sessions") -async def get_sessions(): +async def get_sessions(limit: int = 20, offset: int = 0): try: from hermes_state import SessionDB db = SessionDB() try: - sessions = db.list_sessions_rich(limit=20) + sessions = db.list_sessions_rich(limit=limit, offset=offset) + total = db.session_count() now = time.time() for s in sessions: s["is_active"] = ( s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 ) - return sessions + return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} finally: db.close() except Exception as e: @@ -552,6 +558,881 @@ async def reveal_env_var(body: EnvVarReveal, request: Request): return {"key": body.key, "value": value} +# --------------------------------------------------------------------------- +# OAuth provider endpoints — status + disconnect (Phase 1) +# --------------------------------------------------------------------------- +# +# Phase 1 surfaces *which OAuth providers exist* and whether each is +# connected, plus a disconnect button. The actual login flow (PKCE for +# Anthropic, device-code for Nous/Codex) still runs in the CLI for now; +# Phase 2 will add in-browser flows. For unconnected providers we return +# the canonical ``hermes auth add `` command so the dashboard +# can surface a one-click copy. + + +def _truncate_token(value: Optional[str], visible: int = 6) -> str: + """Return ``...XXXXXX`` (last N chars) for safe display in the UI. + + We never expose more than the trailing ``visible`` characters of an + OAuth access token. JWT prefixes (the part before the first dot) are + stripped first when present so the visible suffix is always part of + the signing region rather than a meaningless header chunk. + """ + if not value: + return "" + s = str(value) + if "." in s and s.count(".") >= 2: + # Looks like a JWT — show the trailing piece of the signature only. + s = s.rsplit(".", 1)[-1] + if len(s) <= visible: + return s + return f"…{s[-visible:]}" + + +def _anthropic_oauth_status() -> Dict[str, Any]: + """Combined status across the three Anthropic credential sources we read. + + Hermes resolves Anthropic creds in this order at runtime: + 1. ``~/.hermes/.anthropic_oauth.json`` — Hermes-managed PKCE flow + 2. ``~/.claude/.credentials.json`` — Claude Code CLI credentials (auto) + 3. ``ANTHROPIC_TOKEN`` / ``ANTHROPIC_API_KEY`` env vars + The dashboard reports the highest-priority source that's actually present. + """ + try: + from agent.anthropic_adapter import ( + read_hermes_oauth_credentials, + read_claude_code_credentials, + _HERMES_OAUTH_FILE, + ) + except ImportError: + read_claude_code_credentials = None # type: ignore + read_hermes_oauth_credentials = None # type: ignore + _HERMES_OAUTH_FILE = None # type: ignore + + hermes_creds = None + if read_hermes_oauth_credentials: + try: + hermes_creds = read_hermes_oauth_credentials() + except Exception: + hermes_creds = None + if hermes_creds and hermes_creds.get("accessToken"): + return { + "logged_in": True, + "source": "hermes_pkce", + "source_label": f"Hermes PKCE ({_HERMES_OAUTH_FILE})", + "token_preview": _truncate_token(hermes_creds.get("accessToken")), + "expires_at": hermes_creds.get("expiresAt"), + "has_refresh_token": bool(hermes_creds.get("refreshToken")), + } + + cc_creds = None + if read_claude_code_credentials: + try: + cc_creds = read_claude_code_credentials() + except Exception: + cc_creds = None + if cc_creds and cc_creds.get("accessToken"): + return { + "logged_in": True, + "source": "claude_code", + "source_label": "Claude Code (~/.claude/.credentials.json)", + "token_preview": _truncate_token(cc_creds.get("accessToken")), + "expires_at": cc_creds.get("expiresAt"), + "has_refresh_token": bool(cc_creds.get("refreshToken")), + } + + env_token = os.getenv("ANTHROPIC_TOKEN") or os.getenv("CLAUDE_CODE_OAUTH_TOKEN") + if env_token: + return { + "logged_in": True, + "source": "env_var", + "source_label": "ANTHROPIC_TOKEN environment variable", + "token_preview": _truncate_token(env_token), + "expires_at": None, + "has_refresh_token": False, + } + return {"logged_in": False, "source": None} + + +def _claude_code_only_status() -> Dict[str, Any]: + """Surface Claude Code CLI credentials as their own provider entry. + + Independent of the Anthropic entry above so users can see whether their + Claude Code subscription tokens are actively flowing into Hermes even + when they also have a separate Hermes-managed PKCE login. + """ + try: + from agent.anthropic_adapter import read_claude_code_credentials + creds = read_claude_code_credentials() + except Exception: + creds = None + if creds and creds.get("accessToken"): + return { + "logged_in": True, + "source": "claude_code_cli", + "source_label": "~/.claude/.credentials.json", + "token_preview": _truncate_token(creds.get("accessToken")), + "expires_at": creds.get("expiresAt"), + "has_refresh_token": bool(creds.get("refreshToken")), + } + return {"logged_in": False, "source": None} + + +# Provider catalog. The order matters — it's how we render the UI list. +# ``cli_command`` is what the dashboard surfaces as the copy-to-clipboard +# fallback while Phase 2 (in-browser flows) isn't built yet. +# ``flow`` describes the OAuth shape so the future modal can pick the +# right UI: ``pkce`` = open URL + paste callback code, ``device_code`` = +# show code + verification URL + poll, ``external`` = read-only (delegated +# to a third-party CLI like Claude Code or Qwen). +_OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = ( + { + "id": "anthropic", + "name": "Anthropic (Claude API)", + "flow": "pkce", + "cli_command": "hermes auth add anthropic", + "docs_url": "https://docs.claude.com/en/api/getting-started", + "status_fn": _anthropic_oauth_status, + }, + { + "id": "claude-code", + "name": "Claude Code (subscription)", + "flow": "external", + "cli_command": "claude setup-token", + "docs_url": "https://docs.claude.com/en/docs/claude-code", + "status_fn": _claude_code_only_status, + }, + { + "id": "nous", + "name": "Nous Portal", + "flow": "device_code", + "cli_command": "hermes auth add nous", + "docs_url": "https://portal.nousresearch.com", + "status_fn": None, # dispatched via auth.get_nous_auth_status + }, + { + "id": "openai-codex", + "name": "OpenAI Codex (ChatGPT)", + "flow": "device_code", + "cli_command": "hermes auth add openai-codex", + "docs_url": "https://platform.openai.com/docs", + "status_fn": None, # dispatched via auth.get_codex_auth_status + }, + { + "id": "qwen-oauth", + "name": "Qwen (via Qwen CLI)", + "flow": "external", + "cli_command": "hermes auth add qwen-oauth", + "docs_url": "https://github.com/QwenLM/qwen-code", + "status_fn": None, # dispatched via auth.get_qwen_auth_status + }, +) + + +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": + raw = hauth.get_nous_auth_status() + 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")), + } + except Exception as e: + return {"logged_in": False, "error": str(e)} + return {"logged_in": False} + + +@app.get("/api/providers/oauth") +async def list_oauth_providers(): + """Enumerate every OAuth-capable LLM provider with current status. + + Response shape (per provider): + id stable identifier (used in DELETE path) + name human label + flow "pkce" | "device_code" | "external" + cli_command fallback CLI command for users to run manually + 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 + """ + providers = [] + for p in _OAUTH_PROVIDER_CATALOG: + status = _resolve_provider_status(p["id"], p.get("status_fn")) + providers.append({ + "id": p["id"], + "name": p["name"], + "flow": p["flow"], + "cli_command": p["cli_command"], + "docs_url": p["docs_url"], + "status": status, + }) + return {"providers": providers} + + +@app.delete("/api/providers/oauth/{provider_id}") +async def disconnect_oauth_provider(provider_id: str, request: Request): + """Disconnect an OAuth provider. Token-protected (matches /env/reveal).""" + auth = request.headers.get("authorization", "") + if auth != f"Bearer {_SESSION_TOKEN}": + raise HTTPException(status_code=401, detail="Unauthorized") + + valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} + if provider_id not in valid_ids: + raise HTTPException( + status_code=400, + detail=f"Unknown provider: {provider_id}. " + f"Available: {', '.join(sorted(valid_ids))}", + ) + + # Anthropic and claude-code clear the same Hermes-managed PKCE file + # AND forget the Claude Code import. We don't touch ~/.claude/* directly + # — that's owned by the Claude Code CLI; users can re-auth there if they + # want to undo a disconnect. + if provider_id in ("anthropic", "claude-code"): + try: + from agent.anthropic_adapter import _HERMES_OAUTH_FILE + if _HERMES_OAUTH_FILE.exists(): + _HERMES_OAUTH_FILE.unlink() + except Exception: + pass + # Also clear the credential pool entry if present. + try: + from hermes_cli.auth import clear_provider_auth + clear_provider_auth("anthropic") + except Exception: + pass + _log.info("oauth/disconnect: %s", provider_id) + return {"ok": True, "provider": provider_id} + + try: + from hermes_cli.auth import clear_provider_auth + cleared = clear_provider_auth(provider_id) + _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)) + + +# --------------------------------------------------------------------------- +# OAuth Phase 2 — in-browser PKCE & device-code flows +# --------------------------------------------------------------------------- +# +# Two flow shapes are supported: +# +# PKCE (Anthropic): +# 1. POST /api/providers/oauth/anthropic/start +# → server generates code_verifier + challenge, builds claude.ai +# authorize URL, stashes verifier in _oauth_sessions[session_id] +# → returns { session_id, flow: "pkce", auth_url } +# 2. UI opens auth_url in a new tab. User authorizes, copies code. +# 3. POST /api/providers/oauth/anthropic/submit { session_id, code } +# → server exchanges (code + verifier) → tokens at console.anthropic.com +# → persists to ~/.hermes/.anthropic_oauth.json AND credential pool +# → returns { ok: true, status: "approved" } +# +# 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 +_oauth_sessions: Dict[str, Dict[str, Any]] = {} +_oauth_sessions_lock = threading.Lock() + +# Import OAuth constants from canonical source instead of duplicating +from agent.anthropic_adapter import ( + _OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID, + _OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL, + _OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI, + _OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES, + _generate_pkce as _generate_pkce_pair, +) +_ANTHROPIC_OAUTH_AUTHORIZE_URL = "https://console.anthropic.com/oauth/authorize" + + +def _gc_oauth_sessions() -> None: + """Drop expired sessions. Called opportunistically on /start.""" + 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 _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]]: + """Create + register a new OAuth session, return (session_id, session_dict).""" + sid = secrets.token_urlsafe(16) + sess = { + "session_id": sid, + "provider": provider_id, + "flow": flow, + "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 _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None: + """Persist Anthropic PKCE creds to both Hermes file AND credential pool. + + Mirrors what auth_commands.add_command does so the dashboard flow leaves + the system in the same state as ``hermes auth add anthropic``. + """ + from agent.anthropic_adapter import _HERMES_OAUTH_FILE + payload = { + "accessToken": access_token, + "refreshToken": refresh_token, + "expiresAt": expires_at_ms, + } + _HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True) + _HERMES_OAUTH_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8") + # Best-effort credential-pool insert. Failure here doesn't invalidate + # the file write — pool registration only matters for the rotation + # strategy, not for runtime credential resolution. + try: + from agent.credential_pool import ( + PooledCredential, + load_pool, + AUTH_TYPE_OAUTH, + SOURCE_MANUAL, + ) + import uuid + pool = load_pool("anthropic") + # Avoid duplicate entries: delete any prior dashboard-issued OAuth entry + existing = [e for e in pool.entries() if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_pkce")] + for e in existing: + try: + pool.remove_entry(getattr(e, "id", "")) + except Exception: + pass + entry = PooledCredential( + provider="anthropic", + id=uuid.uuid4().hex[:6], + label="dashboard PKCE", + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source=f"{SOURCE_MANUAL}:dashboard_pkce", + access_token=access_token, + refresh_token=refresh_token, + expires_at_ms=expires_at_ms, + ) + pool.add_entry(entry) + except Exception as e: + _log.warning("anthropic pool add (dashboard) failed: %s", e) + + +def _start_anthropic_pkce() -> Dict[str, Any]: + """Begin PKCE flow. Returns the auth URL the UI should open.""" + verifier, challenge = _generate_pkce_pair() + sid, sess = _new_oauth_session("anthropic", "pkce") + sess["verifier"] = verifier + sess["state"] = verifier # Anthropic round-trips verifier as state + params = { + "code": "true", + "client_id": _ANTHROPIC_OAUTH_CLIENT_ID, + "response_type": "code", + "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI, + "scope": _ANTHROPIC_OAUTH_SCOPES, + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": verifier, + } + auth_url = f"{_ANTHROPIC_OAUTH_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" + return { + "session_id": sid, + "flow": "pkce", + "auth_url": auth_url, + "expires_in": _OAUTH_SESSION_TTL_SECONDS, + } + + +def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: + """Exchange authorization code for tokens. Persists on success.""" + with _oauth_sessions_lock: + sess = _oauth_sessions.get(session_id) + if not sess or sess["provider"] != "anthropic" or sess["flow"] != "pkce": + raise HTTPException(status_code=404, detail="Unknown or expired session") + if sess["status"] != "pending": + return {"ok": False, "status": sess["status"], "message": sess.get("error_message")} + + # Anthropic's redirect callback page formats the code as `#`. + # Strip the state suffix if present (we already have the verifier server-side). + parts = code_input.strip().split("#", 1) + code = parts[0].strip() + if not code: + return {"ok": False, "status": "error", "message": "No code provided"} + state_from_callback = parts[1] if len(parts) > 1 else "" + + exchange_data = json.dumps({ + "grant_type": "authorization_code", + "client_id": _ANTHROPIC_OAUTH_CLIENT_ID, + "code": code, + "state": state_from_callback or sess["state"], + "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI, + "code_verifier": sess["verifier"], + }).encode() + req = urllib.request.Request( + _ANTHROPIC_OAUTH_TOKEN_URL, + data=exchange_data, + headers={ + "Content-Type": "application/json", + "User-Agent": "hermes-dashboard/1.0", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + result = json.loads(resp.read().decode()) + except Exception as e: + sess["status"] = "error" + sess["error_message"] = f"Token exchange failed: {e}" + return {"ok": False, "status": "error", "message": sess["error_message"]} + + access_token = result.get("access_token", "") + refresh_token = result.get("refresh_token", "") + expires_in = int(result.get("expires_in") or 3600) + if not access_token: + sess["status"] = "error" + sess["error_message"] = "No access token returned" + return {"ok": False, "status": "error", "message": sess["error_message"]} + + expires_at_ms = int(time.time() * 1000) + (expires_in * 1000) + try: + _save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms) + except Exception as e: + sess["status"] = "error" + sess["error_message"] = f"Save failed: {e}" + return {"ok": False, "status": "error", "message": sess["error_message"]} + sess["status"] = "approved" + _log.info("oauth/pkce: anthropic login completed (session=%s)", session_id) + return {"ok": True, "status": "approved"} + + +async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: + """Initiate a device-code flow (Nous or OpenAI Codex). + + 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 import auth as hauth + 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, + ) + device_data = await asyncio.get_event_loop().run_in_executor(None, _do_nous_device_request) + sid, sess = _new_oauth_session("nous", "device_code") + 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 + 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") + # 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.time() + 10 + while time.time() < 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), + } + + raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow") + + +def _nous_poller(session_id: str) -> None: + """Background poller that drives a Nous device-code flow to completion.""" + from hermes_cli.auth import _poll_for_token, refresh_nous_oauth_from_state + from datetime import datetime, timezone + import httpx + with _oauth_sessions_lock: + sess = _oauth_sessions.get(session_id) + if not sess: + return + portal_base_url = sess["portal_base_url"] + client_id = sess["client_id"] + device_code = sess["device_code"] + interval = sess["interval"] + expires_in = max(60, int(sess["expires_at"] - time.time())) + try: + with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client: + token_data = _poll_for_token( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + device_code=device_code, + expires_in=expires_in, + poll_interval=interval, + ) + # Same post-processing as _nous_device_code_login (mint agent key) + now = datetime.now(timezone.utc) + token_ttl = int(token_data.get("expires_in") or 0) + auth_state = { + "portal_base_url": portal_base_url, + "inference_base_url": token_data.get("inference_base_url"), + "client_id": client_id, + "scope": token_data.get("scope"), + "token_type": token_data.get("token_type", "Bearer"), + "access_token": token_data["access_token"], + "refresh_token": token_data.get("refresh_token"), + "obtained_at": now.isoformat(), + "expires_at": ( + datetime.fromtimestamp(now.timestamp() + token_ttl, tz=timezone.utc).isoformat() + if token_ttl else None + ), + "expires_in": token_ttl, + } + full_state = refresh_nous_oauth_from_state( + auth_state, min_key_ttl_seconds=300, timeout_seconds=15.0, + force_refresh=False, force_mint=True, + ) + # Save into credential pool same as auth_commands.py does + from agent.credential_pool import ( + PooledCredential, + load_pool, + AUTH_TYPE_OAUTH, + SOURCE_MANUAL, + ) + pool = load_pool("nous") + entry = PooledCredential.from_dict("nous", { + **full_state, + "label": "dashboard device_code", + "auth_type": AUTH_TYPE_OAUTH, + "source": f"{SOURCE_MANUAL}:dashboard_device_code", + "base_url": full_state.get("inference_base_url"), + }) + pool.add_entry(entry) + with _oauth_sessions_lock: + sess["status"] = "approved" + _log.info("oauth/device: nous login completed (session=%s)", session_id) + except Exception as e: + _log.warning("nous device-code poll failed (session=%s): %s", session_id, e) + with _oauth_sessions_lock: + sess["status"] = "error" + sess["error_message"] = str(e) + + +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. + """ + try: + import httpx + from hermes_cli.auth import ( + CODEX_OAUTH_CLIENT_ID, + CODEX_OAUTH_TOKEN_URL, + DEFAULT_CODEX_BASE_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(f"deviceauth/usercode returned {resp.status_code}") + 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"] + + # Step 2: poll until authorized + deadline = time.time() + sess["expires_in"] + code_resp = None + with httpx.Client(timeout=httpx.Timeout(15.0)) as client: + while time.time() < deadline: + time.sleep(poll_interval) + 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 + + # 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") + + # Persist via credential pool — same shape as auth_commands.add_command + from agent.credential_pool import ( + PooledCredential, + load_pool, + AUTH_TYPE_OAUTH, + SOURCE_MANUAL, + ) + import uuid as _uuid + pool = load_pool("openai-codex") + base_url = ( + os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") + or DEFAULT_CODEX_BASE_URL + ) + entry = PooledCredential( + provider="openai-codex", + id=_uuid.uuid4().hex[:6], + label="dashboard device_code", + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source=f"{SOURCE_MANUAL}:dashboard_device_code", + access_token=access_token, + refresh_token=refresh_token, + base_url=base_url, + ) + pool.add_entry(entry) + with _oauth_sessions_lock: + 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) + + +@app.post("/api/providers/oauth/{provider_id}/start") +async def start_oauth_login(provider_id: str, request: Request): + """Initiate an OAuth login flow. Token-protected.""" + auth = request.headers.get("authorization", "") + if auth != f"Bearer {_SESSION_TOKEN}": + raise HTTPException(status_code=401, detail="Unauthorized") + _gc_oauth_sessions() + 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"] == "pkce": + return _start_anthropic_pkce() + if catalog_entry["flow"] == "device_code": + return await _start_device_code_flow(provider_id) + 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") + + +class OAuthSubmitBody(BaseModel): + session_id: str + code: str + + +@app.post("/api/providers/oauth/{provider_id}/submit") +async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request): + """Submit the auth code for PKCE flows. Token-protected.""" + auth = request.headers.get("authorization", "") + if auth != f"Bearer {_SESSION_TOKEN}": + raise HTTPException(status_code=401, detail="Unauthorized") + if provider_id == "anthropic": + return await asyncio.get_event_loop().run_in_executor( + None, _submit_anthropic_pkce, body.session_id, body.code, + ) + raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}") + + +@app.get("/api/providers/oauth/{provider_id}/poll/{session_id}") +async def poll_oauth_session(provider_id: str, session_id: str): + """Poll a device-code session's status (no auth — read-only state).""" + 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") + return { + "session_id": session_id, + "status": sess["status"], + "error_message": sess.get("error_message"), + "expires_at": sess.get("expires_at"), + } + + +@app.delete("/api/providers/oauth/sessions/{session_id}") +async def cancel_oauth_session(session_id: str, request: Request): + """Cancel a pending OAuth session. Token-protected.""" + auth = request.headers.get("authorization", "") + if auth != f"Bearer {_SESSION_TOKEN}": + raise HTTPException(status_code=401, detail="Unauthorized") + with _oauth_sessions_lock: + sess = _oauth_sessions.pop(session_id, None) + if sess is None: + return {"ok": False, "message": "session not found"} + return {"ok": True, "session_id": session_id} + + # --------------------------------------------------------------------------- # Session detail endpoints # --------------------------------------------------------------------------- @@ -608,6 +1489,7 @@ async def get_logs( lines: int = 100, level: Optional[str] = None, component: Optional[str] = None, + search: Optional[str] = None, ): from hermes_cli.logs import _read_tail, LOG_FILES @@ -623,14 +1505,34 @@ async def get_logs( except ImportError: COMPONENT_PREFIXES = {} - has_filters = bool(level or component) - comp_prefixes = COMPONENT_PREFIXES.get(component, ()) if component else () + # Normalize "ALL" / "all" / empty → no filter. _matches_filters treats an + # empty tuple as "must match a prefix" (startswith(()) is always False), + # so passing () instead of None silently drops every line. + min_level = level if level and level.upper() != "ALL" else None + if component and component.lower() != "all": + comp_prefixes = COMPONENT_PREFIXES.get(component) + if comp_prefixes is None: + raise HTTPException( + status_code=400, + detail=f"Unknown component: {component}. " + f"Available: {', '.join(sorted(COMPONENT_PREFIXES))}", + ) + else: + comp_prefixes = None + + has_filters = bool(min_level or comp_prefixes or search) result = _read_tail( - log_path, min(lines, 500), + log_path, min(lines, 500) if not search else 2000, has_filters=has_filters, - min_level=level, + min_level=min_level, component_prefixes=comp_prefixes, ) + # Post-filter by search term (case-insensitive substring match). + # _read_tail doesn't support free-text search, so we filter here and + # trim to the requested line count afterward. + if search: + needle = search.lower() + result = [l for l in result if needle in l.lower()][-min(lines, 500):] return {"file": file, "lines": result} diff --git a/web/src/App.tsx b/web/src/App.tsx index 6a3073224d..b2f76808ef 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react"; import StatusPage from "@/pages/StatusPage"; import ConfigPage from "@/pages/ConfigPage"; @@ -36,8 +36,15 @@ const PAGE_COMPONENTS: Record = { export default function App() { const [page, setPage] = useState("status"); const [animKey, setAnimKey] = useState(0); + const initialRef = useRef(true); useEffect(() => { + // Skip the animation key bump on initial mount to avoid re-mounting + // the default page component (which causes duplicate API requests). + if (initialRef.current) { + initialRef.current = false; + return; + } setAnimKey((k) => k + 1); }, [page]); diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx new file mode 100644 index 0000000000..836ec4a1ab --- /dev/null +++ b/web/src/components/OAuthLoginModal.tsx @@ -0,0 +1,365 @@ +import { useEffect, useRef, useState } from "react"; +import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react"; +import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +/** + * OAuthLoginModal — drives the in-browser OAuth flow for a single provider. + * + * Two variants share the same modal shell: + * + * - PKCE (Anthropic): user opens the auth URL in a new tab, authorizes, + * pastes the resulting code back. We POST it to /submit which exchanges + * the (code + verifier) pair for tokens server-side. + * + * - Device code (Nous, OpenAI Codex): we display the verification URL + * and short user code; the backend polls the provider's token endpoint + * in a background thread; we poll /poll/{session_id} every 2s for status. + * + * Edge cases handled: + * - Popup blocker (we use plain anchor href + open in new tab; no popup + * window.open which is more likely to be blocked). + * - Modal dismissal mid-flight cancels the server-side session via DELETE. + * - Code expiry surfaces as a clear error state with retry button. + * - Polling continues to work if the user backgrounds the tab (setInterval + * keeps firing in modern browsers; we guard against polls firing after + * component unmount via an isMounted ref). + */ + +interface Props { + provider: OAuthProvider; + onClose: () => void; + onSuccess: (msg: string) => void; + onError: (msg: string) => void; +} + +type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error"; + +export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) { + const [phase, setPhase] = useState("starting"); + const [start, setStart] = useState(null); + const [pkceCode, setPkceCode] = useState(""); + const [errorMsg, setErrorMsg] = useState(null); + const [secondsLeft, setSecondsLeft] = useState(null); + const [codeCopied, setCodeCopied] = useState(false); + const isMounted = useRef(true); + const pollTimer = useRef(null); + + // Initiate flow on mount + useEffect(() => { + isMounted.current = true; + api + .startOAuthLogin(provider.id) + .then((resp) => { + if (!isMounted.current) return; + setStart(resp); + setSecondsLeft(resp.expires_in); + setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user"); + if (resp.flow === "pkce") { + // Auto-open the auth URL in a new tab + window.open(resp.auth_url, "_blank", "noopener,noreferrer"); + } else { + // Device-code: open the verification URL automatically + window.open(resp.verification_url, "_blank", "noopener,noreferrer"); + } + }) + .catch((e) => { + if (!isMounted.current) return; + setPhase("error"); + setErrorMsg(`Failed to start login: ${e}`); + }); + return () => { + isMounted.current = false; + if (pollTimer.current !== null) window.clearInterval(pollTimer.current); + }; + // We only want to start the flow once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Tick the countdown + useEffect(() => { + if (secondsLeft === null) return; + if (phase === "approved" || phase === "error") return; + const tick = window.setInterval(() => { + if (!isMounted.current) return; + setSecondsLeft((s) => { + if (s !== null && s <= 1) { + // Session expired — transition to error state + setPhase("error"); + setErrorMsg("Session expired. Click Retry to start a new login."); + return 0; + } + return s !== null && s > 0 ? s - 1 : 0; + }); + }, 1000); + return () => window.clearInterval(tick); + }, [secondsLeft, phase]); + + // Device-code: poll backend every 2s + useEffect(() => { + if (!start || start.flow !== "device_code" || phase !== "polling") return; + const sid = start.session_id; + pollTimer.current = window.setInterval(async () => { + try { + const resp = await api.pollOAuthSession(provider.id, sid); + if (!isMounted.current) return; + if (resp.status === "approved") { + setPhase("approved"); + if (pollTimer.current !== null) window.clearInterval(pollTimer.current); + onSuccess(`${provider.name} connected`); + window.setTimeout(() => isMounted.current && onClose(), 1500); + } else if (resp.status !== "pending") { + setPhase("error"); + setErrorMsg(resp.error_message || `Login ${resp.status}`); + if (pollTimer.current !== null) window.clearInterval(pollTimer.current); + } + } catch (e) { + // 404 = session expired/cleaned up; treat as error + if (!isMounted.current) return; + setPhase("error"); + setErrorMsg(`Polling failed: ${e}`); + if (pollTimer.current !== null) window.clearInterval(pollTimer.current); + } + }, 2000); + return () => { + if (pollTimer.current !== null) window.clearInterval(pollTimer.current); + }; + }, [start, phase, provider.id, provider.name, onSuccess, onClose]); + + const handleSubmitPkceCode = async () => { + if (!start || start.flow !== "pkce") return; + if (!pkceCode.trim()) return; + setPhase("submitting"); + setErrorMsg(null); + try { + const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim()); + if (!isMounted.current) return; + if (resp.ok && resp.status === "approved") { + setPhase("approved"); + onSuccess(`${provider.name} connected`); + window.setTimeout(() => isMounted.current && onClose(), 1500); + } else { + setPhase("error"); + setErrorMsg(resp.message || "Token exchange failed"); + } + } catch (e) { + if (!isMounted.current) return; + setPhase("error"); + setErrorMsg(`Submit failed: ${e}`); + } + }; + + const handleClose = async () => { + // Cancel server session if still in flight + if (start && phase !== "approved" && phase !== "error") { + try { + await api.cancelOAuthSession(start.session_id); + } catch { + // ignore — server-side TTL will clean it up anyway + } + } + onClose(); + }; + + const handleCopyUserCode = async (code: string) => { + try { + await navigator.clipboard.writeText(code); + setCodeCopied(true); + window.setTimeout(() => isMounted.current && setCodeCopied(false), 1500); + } catch { + onError("Clipboard write failed"); + } + }; + + // Backdrop click closes + const handleBackdrop = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) handleClose(); + }; + + const fmtTime = (s: number | null) => { + if (s === null) return ""; + const m = Math.floor(s / 60); + const r = s % 60; + return `${m}:${String(r).padStart(2, "0")}`; + }; + + return ( +
+
+ +
+
+

+ Connect {provider.name} +

+ {secondsLeft !== null && phase !== "approved" && phase !== "error" && ( +

+ Session expires in {fmtTime(secondsLeft)} +

+ )} +
+ + {/* ── starting ───────────────────────────────────── */} + {phase === "starting" && ( +
+ + Initiating login flow… +
+ )} + + {/* ── PKCE: paste code ───────────────────────────── */} + {start?.flow === "pkce" && phase === "awaiting_user" && ( + <> +
    +
  1. + A new tab opened to claude.ai. Sign in + and click Authorize. +
  2. +
  3. Copy the authorization code shown after authorizing.
  4. +
  5. Paste it below and submit.
  6. +
+
+ setPkceCode(e.target.value)} + placeholder="Paste authorization code (with #state suffix is fine)" + onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()} + autoFocus + /> + +
+ + )} + + {/* ── PKCE: submitting exchange ──────────────────── */} + {phase === "submitting" && ( +
+ + Exchanging code for tokens… +
+ )} + + {/* ── Device code: show code + URL, polling ──────── */} + {start?.flow === "device_code" && phase === "polling" && ( + <> +

+ A new tab opened. Enter this code if prompted: +

+
+ + {(start as Extract).user_code} + + +
+ ).verification_url} + target="_blank" + rel="noopener noreferrer" + className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" + > + + Re-open verification page + +
+ + Waiting for you to authorize in the browser… +
+ + )} + + {/* ── approved ───────────────────────────────────── */} + {phase === "approved" && ( +
+ + Connected! Closing… +
+ )} + + {/* ── error ──────────────────────────────────────── */} + {phase === "error" && ( + <> +
+ {errorMsg || "Login failed."} +
+
+ + +
+ + )} +
+
+
+ ); +} diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx new file mode 100644 index 0000000000..811a65e440 --- /dev/null +++ b/web/src/components/OAuthProvidersCard.tsx @@ -0,0 +1,290 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { ShieldCheck, ShieldOff, Copy, ExternalLink, RefreshCw, LogOut, Terminal, LogIn } from "lucide-react"; +import { api, type OAuthProvider } from "@/lib/api"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { OAuthLoginModal } from "@/components/OAuthLoginModal"; + +/** + * OAuthProvidersCard — surfaces every OAuth-capable LLM provider with its + * current connection status, a truncated token preview when connected, and + * action buttons (Copy CLI command for setup, Disconnect for cleanup). + * + * Phase 1 scope: read-only status + disconnect + copy-to-clipboard CLI + * command. Phase 2 will add in-browser PKCE / device-code flows so users + * never need to drop to a terminal. + */ + +interface Props { + onError?: (msg: string) => void; + onSuccess?: (msg: string) => void; +} + +const FLOW_LABELS: Record = { + pkce: "Browser login (PKCE)", + device_code: "Device code", + external: "External CLI", +}; + +function formatExpiresAt(expiresAt: string | null | undefined): string | null { + if (!expiresAt) return null; + try { + const dt = new Date(expiresAt); + if (Number.isNaN(dt.getTime())) return null; + const now = Date.now(); + const diff = dt.getTime() - now; + if (diff < 0) return "expired"; + const mins = Math.floor(diff / 60_000); + if (mins < 60) return `expires in ${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `expires in ${hours}h`; + const days = Math.floor(hours / 24); + return `expires in ${days}d`; + } catch { + return null; + } +} + +export function OAuthProvidersCard({ onError, onSuccess }: Props) { + const [providers, setProviders] = useState(null); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(null); + const [copiedId, setCopiedId] = useState(null); + // Provider that the login modal is currently open for. null = modal closed. + const [loginFor, setLoginFor] = useState(null); + + // Use refs for callbacks to avoid re-creating refresh() when parent re-renders + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + + const refresh = useCallback(() => { + setLoading(true); + api + .getOAuthProviders() + .then((resp) => setProviders(resp.providers)) + .catch((e) => onErrorRef.current?.(`Failed to load providers: ${e}`)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const handleCopy = async (provider: OAuthProvider) => { + try { + await navigator.clipboard.writeText(provider.cli_command); + setCopiedId(provider.id); + onSuccess?.(`Copied: ${provider.cli_command}`); + setTimeout(() => setCopiedId((v) => (v === provider.id ? null : v)), 1500); + } catch { + onError?.("Clipboard write failed — copy the command manually"); + } + }; + + const handleDisconnect = async (provider: OAuthProvider) => { + if (!confirm(`Disconnect ${provider.name}? You'll need to log in again to use this provider.`)) { + return; + } + setBusyId(provider.id); + try { + await api.disconnectOAuthProvider(provider.id); + onSuccess?.(`${provider.name} disconnected`); + refresh(); + } catch (e) { + onError?.(`Disconnect failed: ${e}`); + } finally { + setBusyId(null); + } + }; + + const connectedCount = providers?.filter((p) => p.status.logged_in).length ?? 0; + const totalCount = providers?.length ?? 0; + + return ( + + +
+
+ + Provider Logins (OAuth) +
+ +
+ + {connectedCount} of {totalCount} OAuth providers connected. Login flows currently + run via the CLI; click Copy command and paste into a terminal to set up. + +
+ + {loading && providers === null && ( +
+
+
+ )} + {providers && providers.length === 0 && ( +

+ No OAuth-capable providers detected. +

+ )} +
+ {providers?.map((p) => { + const expiresLabel = formatExpiresAt(p.status.expires_at); + const isBusy = busyId === p.id; + return ( +
+ {/* Left: status icon + name + source */} +
+ {p.status.logged_in ? ( + + ) : ( + + )} +
+
+ {p.name} + + {FLOW_LABELS[p.flow]} + + {p.status.logged_in && ( + + Connected + + )} + {expiresLabel === "expired" && ( + + Expired + + )} + {expiresLabel && expiresLabel !== "expired" && ( + + {expiresLabel} + + )} +
+ {p.status.logged_in && p.status.token_preview && ( + + token{" "} + {p.status.token_preview} + {p.status.source_label && ( + + {" "}· {p.status.source_label} + + )} + + )} + {!p.status.logged_in && ( + + Not connected. Run{" "} + + {p.cli_command} + {" "} + in a terminal. + + )} + {p.status.error && ( + + {p.status.error} + + )} +
+
+ {/* Right: action buttons */} +
+ {p.docs_url && ( + + + + )} + {!p.status.logged_in && p.flow !== "external" && ( + + )} + {!p.status.logged_in && ( + + )} + {p.status.logged_in && p.flow !== "external" && ( + + )} + {p.status.logged_in && p.flow === "external" && ( + + + Managed externally + + )} +
+
+ ); + })} +
+ + {loginFor && ( + { + setLoginFor(null); + refresh(); // always refresh on close so token preview updates after login + }} + onSuccess={(msg) => onSuccess?.(msg)} + onError={(msg) => onError?.(msg)} + /> + )} + + ); +} diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx index f97c5b7732..e6bb349e89 100644 --- a/web/src/components/Toast.tsx +++ b/web/src/components/Toast.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) { const [visible, setVisible] = useState(false); @@ -17,11 +18,13 @@ export function Toast({ toast }: { toast: { message: string; type: "success" | " if (!current) return null; - return ( + // Portal to document.body so the toast escapes any ancestor stacking context + // (e.g.
has `relative z-2`, which would trap z-50 below the header's z-40). + return createPortal(
{current.message} -
+
, + document.body, ); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 456bb809ea..1c02a11fac 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -22,7 +22,8 @@ async function getSessionToken(): Promise { export const api = { getStatus: () => fetchJSON("/api/status"), - getSessions: () => fetchJSON("/api/sessions"), + getSessions: (limit = 20, offset = 0) => + fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => fetchJSON(`/api/sessions/${encodeURIComponent(id)}/messages`), deleteSession: (id: string) => @@ -110,6 +111,62 @@ export const api = { // Session search (FTS5) searchSessions: (q: string) => fetchJSON(`/api/sessions/search?q=${encodeURIComponent(q)}`), + + // OAuth provider management + getOAuthProviders: () => + fetchJSON("/api/providers/oauth"), + disconnectOAuthProvider: async (providerId: string) => { + const token = await getSessionToken(); + return fetchJSON<{ ok: boolean; provider: string }>( + `/api/providers/oauth/${encodeURIComponent(providerId)}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }, + ); + }, + startOAuthLogin: async (providerId: string) => { + const token = await getSessionToken(); + return fetchJSON( + `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: "{}", + }, + ); + }, + submitOAuthCode: async (providerId: string, sessionId: string, code: string) => { + const token = await getSessionToken(); + return fetchJSON( + `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ session_id: sessionId, code }), + }, + ); + }, + pollOAuthSession: (providerId: string, sessionId: string) => + fetchJSON( + `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`, + ), + cancelOAuthSession: async (sessionId: string) => { + const token = await getSessionToken(); + return fetchJSON<{ ok: boolean }>( + `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }, + ); + }, }; export interface PlatformStatus { @@ -152,6 +209,13 @@ export interface SessionInfo { preview: string | null; } +export interface PaginatedSessions { + sessions: SessionInfo[]; + total: number; + limit: number; + offset: number; +} + export interface EnvVarInfo { is_set: boolean; redacted_value: string | null; @@ -260,3 +324,61 @@ export interface SessionSearchResult { export interface SessionSearchResponse { results: SessionSearchResult[]; } + +// ── OAuth provider types ──────────────────────────────────────────────── + +export interface OAuthProviderStatus { + logged_in: boolean; + source?: string | null; + source_label?: string | null; + token_preview?: string | null; + expires_at?: string | null; + has_refresh_token?: boolean; + last_refresh?: string | null; + error?: string; +} + +export interface OAuthProvider { + id: string; + name: string; + /** "pkce" (browser redirect + paste code), "device_code" (show code + URL), + * or "external" (delegated to a separate CLI like Claude Code or Qwen). */ + flow: "pkce" | "device_code" | "external"; + cli_command: string; + docs_url: string; + status: OAuthProviderStatus; +} + +export interface OAuthProvidersResponse { + providers: OAuthProvider[]; +} + +/** Discriminated union — the shape of /start depends on the flow. */ +export type OAuthStartResponse = + | { + session_id: string; + flow: "pkce"; + auth_url: string; + expires_in: number; + } + | { + session_id: string; + flow: "device_code"; + user_code: string; + verification_url: string; + expires_in: number; + poll_interval: number; + }; + +export interface OAuthSubmitResponse { + ok: boolean; + status: "approved" | "error"; + message?: string; +} + +export interface OAuthPollResponse { + session_id: string; + status: "pending" | "approved" | "denied" | "expired" | "error"; + error_message?: string | null; + expires_at?: number | null; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 15753afa9e..91a0e623e8 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,10 +1,7 @@ -import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App"; createRoot(document.getElementById("root")!).render( - - - , + , ); diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 5c9e8d6057..9c3d6f99dc 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -1,9 +1,7 @@ import { useEffect, useState, useCallback } from "react"; import { BarChart3, - Coins, Cpu, - Database, Hash, TrendingUp, } from "lucide-react"; @@ -26,17 +24,6 @@ function formatTokens(n: number): string { return String(n); } -function formatCost(n: number): string { - if (n < 0.01) return `$${n.toFixed(4)}`; - return `$${n.toFixed(2)}`; -} - -/** Pick the best cost value: actual > estimated > 0 */ -function bestCost(entry: { estimated_cost: number; actual_cost?: number }): number { - if (entry.actual_cost && entry.actual_cost > 0) return entry.actual_cost; - return entry.estimated_cost; -} - function formatDate(day: string): string { try { const d = new Date(day + "T00:00:00"); @@ -100,9 +87,6 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { const total = d.input_tokens + d.output_tokens; const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX); const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX); - const cacheReadPct = d.cache_read_tokens > 0 - ? Math.round((d.cache_read_tokens / (d.input_tokens + d.cache_read_tokens)) * 100) - : 0; return (
{formatDate(d.day)}
Input: {formatTokens(d.input_tokens)}
Output: {formatTokens(d.output_tokens)}
- {cacheReadPct > 0 &&
Cache hit: {cacheReadPct}%
}
Total: {formatTokens(total)}
- {bestCost(d) > 0 &&
Cost: {formatCost(bestCost(d))}
} {/* Input bar */} @@ -168,17 +150,11 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { Date Sessions Input - Output - Cache Hit - Cost + Output {sorted.map((d) => { - const cost = bestCost(d); - const cacheHitPct = d.cache_read_tokens > 0 && d.input_tokens > 0 - ? Math.round((d.cache_read_tokens / d.input_tokens) * 100) - : 0; return ( {formatDate(d.day)} @@ -186,15 +162,9 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { {formatTokens(d.input_tokens)} - + {formatTokens(d.output_tokens)} - - {cacheHitPct > 0 ? `${cacheHitPct}%` : "—"} - - - {cost > 0 ? formatCost(cost) : "—"} - ); })} @@ -228,8 +198,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { Model Sessions - Tokens - Cost + Tokens @@ -239,14 +208,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { {m.model} {m.sessions} - + {formatTokens(m.input_tokens)} {" / "} {formatTokens(m.output_tokens)} - - {m.estimated_cost > 0 ? formatCost(m.estimated_cost) : "—"} - ))} @@ -311,38 +277,26 @@ export default function AnalyticsPage() { {data && ( <> - {/* Summary cards — matches hermes's token model */} -
+ {/* Summary cards */} +
- 0 - ? `${Math.round((data.totals.total_cache_read / (data.totals.total_input + data.totals.total_cache_read)) * 100)}%` - : "—"} - sub={`${formatTokens(data.totals.total_cache_read)} tokens from cache`} - /> - 0 - ? data.totals.total_actual_cost - : data.totals.total_estimated_cost - )} - sub={data.totals.total_actual_cost > 0 ? "actual" : `estimated · last ${days}d`} - /> + sum + d.sessions, 0))} + sub={`across ${data.by_model.length} models`} + />
{/* Bar chart */} diff --git a/web/src/pages/EnvPage.tsx b/web/src/pages/EnvPage.tsx index f3b54d647e..eeb8fe17d5 100644 --- a/web/src/pages/EnvPage.tsx +++ b/web/src/pages/EnvPage.tsx @@ -18,6 +18,7 @@ import { api } from "@/lib/api"; import type { EnvVarInfo } from "@/lib/api"; import { useToast } from "@/hooks/useToast"; import { Toast } from "@/components/Toast"; +import { OAuthProvidersCard } from "@/components/OAuthProvidersCard"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -495,6 +496,12 @@ export default function EnvPage() {
+ {/* ═══════════════ OAuth Logins (sits above API keys — distinct auth mode) ══ */} + showToast(msg, "error")} + onSuccess={(msg) => showToast(msg, "success")} + /> + {/* ═══════════════ LLM Providers (grouped) ═══════════════ */} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 2d25f6ca6a..6454ae0a65 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { ChevronDown, + ChevronLeft, ChevronRight, MessageSquare, Search, @@ -287,6 +288,9 @@ function SessionRow({ export default function SessionsPage() { const [sessions, setSessions] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const PAGE_SIZE = 20; const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [expandedId, setExpandedId] = useState(null); @@ -294,17 +298,21 @@ export default function SessionsPage() { const [searching, setSearching] = useState(false); const debounceRef = useRef>(null); - const loadSessions = useCallback(() => { + const loadSessions = useCallback((p: number) => { + setLoading(true); api - .getSessions() - .then(setSessions) + .getSessions(PAGE_SIZE, p * PAGE_SIZE) + .then((resp) => { + setSessions(resp.sessions); + setTotal(resp.total); + }) .catch(() => {}) .finally(() => setLoading(false)); }, []); useEffect(() => { - loadSessions(); - }, [loadSessions]); + loadSessions(page); + }, [loadSessions, page]); // Debounced FTS search useEffect(() => { @@ -334,6 +342,7 @@ export default function SessionsPage() { try { await api.deleteSession(id); setSessions((prev) => prev.filter((s) => s.id !== id)); + setTotal((prev) => prev - 1); if (expandedId === id) setExpandedId(null); } catch { // ignore @@ -370,7 +379,7 @@ export default function SessionsPage() {

Sessions

- {sessions.length} + {total}
@@ -408,21 +417,57 @@ export default function SessionsPage() { )}
) : ( -
- {filtered.map((s) => ( - - setExpandedId((prev) => (prev === s.id ? null : s.id)) - } - onDelete={() => handleDelete(s.id)} - /> - ))} -
+ <> +
+ {filtered.map((s) => ( + + setExpandedId((prev) => (prev === s.id ? null : s.id)) + } + onDelete={() => handleDelete(s.id)} + /> + ))} +
+ + {/* Pagination — hidden during search */} + {!searchResults && total > PAGE_SIZE && ( +
+ + {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total} + +
+ + + Page {page + 1} of {Math.ceil(total / PAGE_SIZE)} + + +
+
+ )} + )} ); diff --git a/web/src/pages/StatusPage.tsx b/web/src/pages/StatusPage.tsx index 680f8dad78..06fb7c25ed 100644 --- a/web/src/pages/StatusPage.tsx +++ b/web/src/pages/StatusPage.tsx @@ -49,7 +49,7 @@ export default function StatusPage() { useEffect(() => { const load = () => { api.getStatus().then(setStatus).catch(() => {}); - api.getSessions().then(setSessions).catch(() => {}); + api.getSessions(50).then((resp) => setSessions(resp.sessions)).catch(() => {}); }; load(); const interval = setInterval(load, 5000);