From 60dfa0f31b98411e5be857f16400b36664e3d8bd Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 18 Jun 2026 23:21:23 -0400 Subject: [PATCH] feat(desktop): Accounts tab derives membership from unified provider catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/providers/oauth now unions the explicit hand-tuned OAuth cards (_OAUTH_PROVIDER_CATALOG — bespoke flow/status/cli, plus the api-key Anthropic PKCE card and synthetic claude-code row) with every accounts-tab provider in provider_catalog(). Any OAuth/external provider in the `hermes model` universe now appears automatically, closing the drift where google-gemini-cli and copilot-acp had no Accounts card despite being CLI-configurable. Adds read-only status cards for google-gemini-cli (via existing get_gemini_oauth_auth_status) and copilot-acp (managed-by-CLI, like claude-code). DELETE handler routes through the same _build_oauth_catalog() builder. Parity test asserts the Accounts tab offers every accounts-tab catalog provider as an invariant. --- hermes_cli/web_server.py | 108 +++++++++++++++++++- tests/hermes_cli/test_web_oauth_dispatch.py | 33 ++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ddd9b3c3d3d..fbdbff3723f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5550,6 +5550,40 @@ def _claude_code_only_status() -> Dict[str, Any]: return {"logged_in": False, "source": None} +def _gemini_cli_status() -> Dict[str, Any]: + """Status for the google-gemini-cli OAuth provider (Code Assist login).""" + try: + from hermes_cli import auth as hauth + raw = hauth.get_gemini_oauth_auth_status() + except Exception as e: + return {"logged_in": False, "error": str(e)} + return { + "logged_in": bool(raw.get("logged_in")), + "source": raw.get("source") or "google_oauth", + "source_label": raw.get("email") or raw.get("auth_file") or "Google Code Assist", + "token_preview": _truncate_token(raw.get("api_key")), + "expires_at": None, + "has_refresh_token": True, + } + + +def _copilot_acp_status() -> Dict[str, Any]: + """Status for copilot-acp — credentials are owned by the Copilot CLI. + + There is no cheap programmatic credential probe for the ACP subprocess, so + this is a read-only "managed by the Copilot CLI" card (like claude-code): + Hermes never claims a login state it can't verify. + """ + return { + "logged_in": False, + "source": "copilot_cli", + "source_label": "Managed by the GitHub Copilot CLI", + "token_preview": None, + "expires_at": None, + "has_refresh_token": False, + } + + # 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. @@ -5606,6 +5640,22 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = ( "docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth", "status_fn": None, # dispatched via auth.get_xai_oauth_auth_status }, + { + "id": "google-gemini-cli", + "name": "Google Gemini (OAuth + Code Assist)", + "flow": "external", + "cli_command": "hermes auth add google-gemini-cli", + "docs_url": "https://ai.google.dev/gemini-api/docs", + "status_fn": _gemini_cli_status, + }, + { + "id": "copilot-acp", + "name": "GitHub Copilot (ACP)", + "flow": "external", + "cli_command": "copilot /login", + "docs_url": "https://docs.github.com/en/copilot", + "status_fn": _copilot_acp_status, + }, # ── Anthropic / Claude entries sit at the bottom: the API-key path # first, then the subscription OAuth path (which only works with extra # usage credits on top of a Claude Max plan — see disclaimer in name). @@ -5735,6 +5785,56 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, 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. + """ + 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 + + @app.get("/api/providers/oauth") async def list_oauth_providers(profile: Optional[str] = None): """Enumerate every OAuth-capable LLM provider with current status. @@ -5754,10 +5854,14 @@ async def list_oauth_providers(profile: Optional[str] = None): 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. """ with _profile_scope(profile): providers = [] - for p in _OAUTH_PROVIDER_CATALOG: + 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({ @@ -5784,7 +5888,7 @@ async def disconnect_oauth_provider( _require_token(request) with _profile_scope(profile): - catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG} + 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( diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 1d87573fe58..f233fd3272d 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -470,6 +470,39 @@ def test_xai_oauth_listed_as_loopback_flow(): assert "grok" in providers["xai-oauth"]["name"].lower() +def test_accounts_offers_every_oauth_provider_from_catalog(): + """PARITY CONTRACT: every accounts-tab provider in the unified catalog (the + `hermes model` universe) must be offered by /api/providers/oauth. This keeps + the desktop Accounts tab in lockstep with the CLI picker — no provider the + CLI can sign into may be missing from the GUI. + """ + from hermes_cli.provider_catalog import provider_catalog + + resp = client.get("/api/providers/oauth", headers=HEADERS) + assert resp.status_code == 200, resp.text + offered = {p["id"] for p in resp.json()["providers"]} + for d in provider_catalog(): + if d.tab == "accounts": + assert d.slug in offered, ( + f"{d.slug} is an accounts-tab provider in `hermes model` but is " + f"missing from the desktop Accounts tab (/api/providers/oauth)" + ) + + +def test_gemini_cli_and_copilot_acp_now_in_accounts(): + """Regression: google-gemini-cli and copilot-acp were canonical providers the + CLI could configure, but had no Accounts card (the reported GUI/CLI drift). + """ + resp = client.get("/api/providers/oauth", headers=HEADERS) + assert resp.status_code == 200, resp.text + providers = {p["id"]: p for p in resp.json()["providers"]} + assert "google-gemini-cli" in providers + assert "copilot-acp" in providers + # copilot-acp is managed by an external CLI: read-only card, not auto-removable. + assert providers["copilot-acp"]["flow"] == "external" + assert providers["copilot-acp"]["disconnectable"] is False + + def test_oauth_catalog_marks_external_providers_not_disconnectable(): """External CLI credentials are visible in Accounts but cannot be removed by Hermes.""" resp = client.get("/api/providers/oauth", headers=HEADERS)