Dashboard 500 when basic auth is sole provider

NousResearch/hermes-agent · cluster 55130 · dashboard

Data as of

Nothing open on GitHub. Fix merged in #61330. Check it covers your case.

The short answer

0open issues
0open PRs
48items grouped
Known?
Yes. 48 related items grouped here, 25 issues already closed.
Fix event
A fix was merged to main in PR #61330. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

A bug where the dashboard crashes with HTTP 500 on login when BasicAuth is the only configured provider, because the auto-SSO redirect incorrectly routes password-only providers through an OAuth-only code path. Multiple issues and fix attempts address this same problem.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #61330 fix(dashboard): support mobile/hosted OpenAI OAuth login (salvage #61281)

    merged to main

    files: hermes_cli/dashboard_auth/middleware.py, hermes_cli/dashboard_auth/routes.py, hermes_cli/web_server.py, tests/hermes_cli/test_dashboard_auth_password_login.py, tests/hermes_cli/test_dashboard_oauth_endpoints_server_gate.py, tests/hermes_cli/test_web_oauth_dispatch.py, web/src/components/OAuthLoginModal.tsx, web/src/i18n/af.ts, web/src/i18n/de.ts, web/src/i18n/en.ts, web/src/i18n/es.ts, web/src/i18n/fr.ts, web/src/i18n/ga.ts, web/src/i18n/hu.ts, web/src/i18n/it.ts, web/src/i18n/ja.ts, web/src/i18n/ko.ts, web/src/i18n/pt.ts, web/src/i18n/ru.ts, web/src/i18n/tr.ts (+8 more)

    Show the diff
    • hermes_cli/dashboard_auth/middleware.py
      @@ -150,6 +150,8 @@ def _auto_sso_response(request: Request) -> Response | None:
             * exactly ONE interactive provider is registered — with two or more we
               can't pick for the user, so the ``/login`` chooser must render; with
               zero there's nothing to redirect to;
      +      * that provider is OAuth-style, not a password form provider. Password
      +        providers must render ``/login`` so the user can enter credentials;
             * the one-shot loop-guard marker is ABSENT. Its presence means we
               already bounced to the portal once and came back still
               unauthenticated (no portal session) — auto-redirecting again would
      @@ -185,6 +187,9 @@ def _auto_sso_response(request: Request) -> Response | None:
           from hermes_cli.dashboard_auth.prefix import prefix_from_request
       
           provider = providers[0]
      +    if getattr(provider, "supports_password", False):
      +        return None
      +
           prefix = prefix_from_request(request)
           next_param = _safe_next_target(request)
           from urllib.parse import quote
      @@ -458,4 +463,3 @@ def _attempt_refresh(request: Request, *, refresh_token):
               if new_session is not None:
                   return new_session, provider.name
           return None
      -
    • hermes_cli/dashboard_auth/routes.py
      @@ -192,6 +192,14 @@ async def auth_login(request: Request, provider: str, next: str = ""):
                   status_code=404,
                   detail=f"Provider does not support interactive login: {provider!r}",
               )
      +    if getattr(p, "supports_password", False):
      +        from urllib.parse import quote
      +
      +        safe_next = _validate_post_login_target(next)
      +        login_url = f"{_prefix(request)}/login"
      +        if safe_next:
      +            login_url = f"{login_url}?next={quote(safe_next, safe='')}"
      +        return RedirectResponse(url=login_url, status_code=302)
       
           try:
               ls = p.start_login(redirect_uri=_redirect_uri(request))
    • hermes_cli/web_server.py
      @@ -9087,6 +9087,54 @@ def _xai_device_poller(session_id: str) -> None:
                   sess["error_message"] = str(e)
       
       
      +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 _codex_full_login_worker(session_id: str) -> None:
           """Run the complete OpenAI Codex device-code flow.
       
      @@ -9119,7 +9167,7 @@ def _codex_full_login_worker(session_id: str) -> None:
                       headers={"Content-Type": "application/json"},
                   )
               if resp.status_code != 200:
      -            raise RuntimeError(f"deviceauth/usercode returned {resp.status_code}")
      +            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", "")
    • tests/hermes_cli/test_dashboard_auth_password_login.py
      @@ -198,6 +198,24 @@ def test_providers_endpoint_reports_supports_password(self, gated_app):
               prov = {p["name"]: p for p in resp.json()["providers"]}
               assert prov["testpw"]["supports_password"] is True
       
      +    def test_password_provider_html_redirects_to_login_form(self, gated_app):
      +        resp = gated_app.get("/", follow_redirects=False)
      +        assert resp.status_code == 302
      +        assert resp.headers["location"] == "/login?next=%2F"
      +
      +        login = gated_app.get(resp.headers["location"])
      +        assert login.status_code == 200
      +        assert '<form class="provider-form" data-provider="testpw"' in login.text
      +        assert "/auth/password-login" in login.text
      +
      +    def test_password_provider_auth_login_redirects_to_login_form(self, gated_app):
      +        resp = gated_app.get(
      +            "/auth/login?provider=testpw&next=%2F",
      +            follow_redirects=False,
      +        )
      +        assert resp.status_code == 302
      +        assert resp.headers["location"] == "/login?next=%2F"
      +
           def test_oauth_provider_reports_false(self):
               clear_providers()
               register_provider(StubAuthProvider())
    • tests/hermes_cli/test_dashboard_oauth_endpoints_server_gate.py
      @@ -0,0 +1,69 @@
      +"""Regression guard for PR #61281 (mobile/hosted dashboard OAuth).
      +
      +The PR removed the *client-side* ``X-Hermes-Session-Token`` requirement from
      +the dashboard OAuth mutation calls (``web/src/lib/api.ts``) so that
      +cookie-authenticated hosted/mobile sessions can start provider logins. The
      +safety of that change rests entirely on the *server* still gating those
      +endpoints: in gated mode the ``gated_auth_middleware`` verifies the session
      +cookie before the handler runs, and ``_require_token`` defers to it.
      +
      +These tests pin that server-side gate for the exact endpoints whose
      +client-side token gate was removed. Without them, a future change that
      +re-broke ``_require_token``'s gated-mode branch (e.g. letting it fall through
      +without a session) would still pass the PR's ``api.test.ts`` suite, because
      +those tests only mock ``fetch`` and never touch the server.
      +"""
      +
      +import pytest
      +from fastapi.testclient import TestClient
      +
      +from hermes_cli import web_server
      +from hermes_cli.dashboard_auth import clear_providers, register_provider
      +from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
      +
      +
      [redacted:email]
      +def gated_app():
      +    """A gated (``auth_required``) dashboard with no session cookie set."""
      +    clear_providers()
      +    register_provider(StubAuthProvider())
      +    prev_host = getattr(web_server.app.state, "bound_host", None)
      +    prev_port = getattr(web_server.app.state, "bound_port", None)
      +    prev_required = getattr(web_server.app.state, "auth_required", None)
      +    web_server.app.state.bound_host = "fly-app.fly.dev"
      +    web_server.app.state.bound_port = 443
      +    web_server.app.state.auth_required = True
      +    client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
      +    yield client
      +    clear_providers()
      +    web_server.app.state.bound_host = prev_host
      +    web_server.app.state.bound_port = prev_port
      +    web_server.app.state.auth_required = prev_required
      +
      +
      +class TestOAuthMutationEndpointsGatedWithoutCookie:
      +    """No cookie in gated mode -> 401 on every endpoint whose client-side
      +    session-token gate PR #61281 removed."""
      +
      +    def test_env_reveal_requires_cookie(self, gated_app):
      +        r = gated_app.post("/api/env/reveal", json={"key": "OPENAI_API_KEY"})
      +        assert r.status_code == 401
      +
      +    def test_oauth_disconnect_requires_cookie(self, gated_app):
      +        r = gated_app.delete("/api/providers/oauth/anthropic")
      +        assert r.status_code == 401
      +
      +    def test_oauth_start_requires_cookie(self, gated_app):
      +        r = gated_app.post("/api/providers/oauth/anthropic/start", json={})
      +        assert r.status_code == 401
      +
      +    def test_oauth_submit_requires_cookie(self, gated_app):
      +        r = gated_app.post(
      +            "/api/providers/oauth/anthropic/submit",
      +            json={"session_id": "sid", "code": "abc"},
      +        )
      +        assert r.status_code == 401
      +
      +    def test_oauth_cancel_session_requires_cookie(self, gated_app):
      +        r = gated_app.delete("/api/providers/oauth/sessions/sid")
      +        assert r.status_code == 401
    • tests/hermes_cli/test_web_oauth_dispatch.py
      @@ -340,6 +340,56 @@ def post(self, url, **kwargs):
               ws._oauth_sessions.pop(sid, None)
       
       
      +def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch):
      +    from hermes_cli import web_server as ws
      +
      +    before_sessions = set(ws._oauth_sessions)
      +
      +    class _Resp:
      +        status_code = 400
      +        text = "Enable device code authorization"
      +
      +        def json(self):
      +            return {
      +                "error": {
      +                    "message": "Enable device code authorization",
      +                    "code": "device_authorization_not_enabled",
      +                }
      +            }
      +
      +    class _Client:
      +        def __init__(self, *args, **kwargs):
      +            pass
      +
      +        def __enter__(self):
      +            return self
      +
      +        def __exit__(self, *args):
      +            return False
      +
      +        def post(self, url, **kwargs):
      +            assert url.endswith("/deviceauth/usercode")
      +            return _Resp()
      +
      +    monkeypatch.setattr(httpx, "Client", _Client)
      +
      +    try:
      +        resp = client.post(
      +            "/api/providers/oauth/openai-codex/start",
      +            headers=HEADERS,
      +        )
      +
      +        assert resp.status_code == 500
      +        detail = resp.json()["detail"]
      +        assert "OpenAI rejected the device-code login request" in detail
      +        assert "Enable device-code authorization in OpenAI" in detail
      +        assert "click Login again" in detail
      +        assert "hermes auth" not in detail
      +    finally:
      +        for sid in set(ws._oauth_sessions) - before_sessions:
      +            ws._oauth_sessions.pop(sid, None)
      +
      +
       def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
           from hermes_cli import auth as auth_mod
           from hermes_cli import web_server as ws
    • web/src/components/OAuthLoginModal.tsx
      @@ -1,10 +1,10 @@
       import { useEffect, useRef, useState } from "react";
      -import { ExternalLink, X, Check } from "lucide-react";
      +import { ExternalLink, X, Check, Copy } from "lucide-react";
       import { Button } from "@nous-research/ui/ui/components/button";
      -import { CopyButton } from "@nous-research/ui/ui/components/command-block";
       import { Spinner } from "@nous-research/ui/ui/components/spinner";
       import { H2 } from "@nous-research/ui/ui/components/typography/h2";
       import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
      +import { copyTextToClipboard } from "@/lib/clipboard";
       import { Input } from "@nous-research/ui/ui/components/input";
       import { useI18n } from "@/i18n";
       import { cn, themedBody } from "@/lib/utils";
      @@ -30,9 +30,13 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
         const [start, setStart] = useState<OAuthStartResponse | null>(null);
         const [pkceCode, setPkceCode] = useState("");
         const [errorMsg, setErrorMsg] = useState<string | null>(null);
      +  const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">(
      +    "idle",
      +  );
         const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
         const isMounted = useRef(true);
         const pollTimer = useRef<number | null>(null);
      +  const copyResetTimer = useRef<number | null>(null);
         const { t } = useI18n();
       
         // Initiate flow on mount
      @@ -59,6 +63,8 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
           return () => {
             isMounted.current = false;
             if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
      +      if (copyResetTimer.current !== null)
      +        window.clearTimeout(copyResetTimer.current);
           };
           // eslint-disable-next-line react-hooks/exhaustive-deps
         }, []);
      @@ -162,6 +168,24 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
           return `${m}:${String(r).padStart(2, "0")}`;
         };
       
      +  const handleCopyDeviceCode = async (code: string) => {
      +    if (copyResetTimer.current !== null) {
      +      window.clearTimeout(copyResetTimer.current);
      +      copyResetTimer.current = null;
      +    }
      +    const copied = await copyTextToClipboard(code);
      +    if (!isMounted.current) return;
      +    setCopyStatus(copied ? "copied" : "failed");
      +    copyResetTimer.current = window.setTimeout(() => {
      +      if (isMounted.current) setCopyStatus("idle");
      +      copyResetTimer.current = null;
      +    }, 2000);
      +  };
      +
      +  const deviceCode = start?.flow === "device_code" ? start.user_code : "";
      +  const verificationUrl =
      +    start?.flow === "device_code" ? start.verification_url : "";
      +
         return (
           <div
             className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
      @@ -262,35 +286,32 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
                     </p>
                     <div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
                       <code className="font-mono-ui text-2xl tracking-widest text-foreground">
      -                  {
      -                    (
      -                      start as Extract<
      -                        OAuthStartResponse,
      -                        { flow: "device_code" }
      -                      >
      -                    ).user_code
      -                  }
      +                  {deviceCode}
                       </code>
      -                <CopyButton
      -                  text={
      -                    (
      -                      start as Extract<
      -                        OAuthStartResponse,
      -                        { flow: "device_code" }
      -                      >
      -                    ).user_code
      +                <Button
      +                  size="sm"
      +                  outlined
      +                  className="shrink-0 uppercase"
      +                  onClick={() => void handleCopyDeviceCode(deviceCode)}
      +                  prefix={
      +                    copyStatus === "copied" ? (
      +                      <Check className="h-4 w-4" />
      +                    ) : (
      +                      <Copy className="h-4 w-4" />
      +                    )
                         }
      -                />
      +                  aria-label={t.oauth.copyCode}
      +                >
      +                  {copyStatus === "copied" ? t.oauth.copied : t.oauth.copyCode}
      +                </Button>
                     </div>
      +              {copyStatus === "failed" && (
      +                <p className="text-xs text-destructive">
      +                  {t.oauth.copyFailed}
      +                </p>
      +              )}
                     <a
      -                href={
      -                  (
      -                    start as Extract<
      -                      OAuthStartResponse,
      -                      { flow: "device_code" }
      -                    >
      -                  ).verification_url
      -                }
      +                href={verificationUrl}
                       target="_blank"
                       rel="noopener noreferrer"
                       className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
    • web/src/i18n/af.ts
      @@ -446,16 +446,19 @@ export const af: Translations = {
         oauth: {
           title: "Verskaffer-aanmeldings (OAuth)",
           providerLogins: "Verskaffer-aanmeldings (OAuth)",
      -    description: "{connected} van {total} OAuth-verskaffers gekoppel. Aanmeldvloei loop tans via die CLI; klik Kopieer opdrag en plak in 'n terminaal om op te stel.",
      +    description:
      +      "{connected} van {total} OAuth-verskaffers gekoppel. Gebruik Meld aan vir vloeie wat die kontroleskerm ondersteun; CLI-opdragte bly beskikbaar vir eksterne of terugval-opstelling.",
           connected: "Gekoppel",
           expired: "Verval",
      -    notConnected: "Nie gekoppel nie. Voer {command} uit in 'n terminaal.",
      +    notConnected: "Nie gekoppel nie. Gebruik Meld aan indien beskikbaar, of voer {command} uit in 'n terminaal.",
           runInTerminal: "in 'n terminaal.",
           noProviders: "Geen OAuth-bekwame verskaffers opgespoor nie.",
           login: "Meld aan",
           disconnect: "Ontkoppel",
           managedExternally: "Ekstern bestuur",
           copied: "Gekopieer ✓",
      +    copyCode: "Kopieer kode",
      +    copyFailed: "Kon nie outomaties kopieer nie. Kies die kode en kopieer dit met die hand.",
           cli: "Kopieer",
           copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)",
           connect: "Koppel",
    • web/src/i18n/de.ts
      @@ -446,16 +446,19 @@ export const de: Translations = {
         oauth: {
           title: "Anbieter-Logins (OAuth)",
           providerLogins: "Anbieter-Logins (OAuth)",
      -    description: "{connected} von {total} OAuth-Anbietern verbunden. Login-Abläufe laufen derzeit über die CLI; klicke auf Befehl kopieren und füge ihn in ein Terminal ein, um einzurichten.",
      +    description:
      +      "{connected} von {total} OAuth-Anbietern verbunden. Nutze Anmelden für vom Dashboard unterstützte Abläufe; CLI-Befehle bleiben für externe oder Fallback-Einrichtung verfügbar.",
           connected: "Verbunden",
           expired: "Abgelaufen",
      -    notConnected: "Nicht verbunden. Führe {command} in einem Terminal aus.",
      +    notConnected: "Nicht verbunden. Nutze Anmelden, falls verfügbar, oder führe {command} in einem Terminal aus.",
           runInTerminal: "in einem Terminal.",
           noProviders: "Keine OAuth-fähigen Anbieter erkannt.",
           login: "Anmelden",
           disconnect: "Trennen",
           managedExternally: "Extern verwaltet",
           copied: "Kopiert ✓",
      +    copyCode: "Code kopieren",
      +    copyFailed: "Automatisches Kopieren fehlgeschlagen. Markiere den Code und kopiere ihn manuell.",
           cli: "Kopieren",
           copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)",
           connect: "Verbinden",
    • web/src/i18n/en.ts
      @@ -501,16 +501,19 @@ export const en: Translations = {
         oauth: {
           title: "Provider Logins (OAuth)",
           providerLogins: "Provider Logins (OAuth)",
      -    description: "{connected} of {total} OAuth providers connected. Login flows currently run via the CLI; click Copy command and paste into a terminal to set up.",
      +    description:
      +      "{connected} of {total} OAuth providers connected. Use Login for dashboard-supported flows; CLI commands remain available for external or fallback setup.",
           connected: "Connected",
           expired: "Expired",
      -    notConnected: "Not connected. Run {command} in a terminal.",
      +    notConnected: "Not connected. Use Login when available, or run {command} in a terminal.",
           runInTerminal: "in a terminal.",
           noProviders: "No OAuth-capable providers detected.",
           login: "Login",
           disconnect: "Disconnect",
           managedExternally: "Managed externally",
           copied: "Copied ✓",
      +    copyCode: "Copy code",
      +    copyFailed: "Could not copy automatically. Select the code and copy it manually.",
           cli: "Copy",
           copyCliCommand: "Copy CLI command (for external / fallback)",
           connect: "Connect",
    • web/src/i18n/es.ts
      @@ -447,16 +447,19 @@ export const es: Translations = {
         oauth: {
           title: "Inicios de sesión de proveedores (OAuth)",
           providerLogins: "Inicios de sesión de proveedores (OAuth)",
      -    description: "{connected} de {total} proveedores OAuth conectados. Los flujos de inicio de sesión actualmente se ejecutan a través de la CLI; haz clic en Copiar comando y pégalo en una terminal para configurar.",
      +    description:
      +      "{connected} de {total} proveedores OAuth conectados. Usa Iniciar sesión para los flujos compatibles con el panel; los comandos CLI siguen disponibles para configuración externa o de respaldo.",
           connected: "Conectado",
           expired: "Caducado",
      -    notConnected: "No conectado. Ejecuta {command} en una terminal.",
      +    notConnected: "No conectado. Usa Iniciar sesión si está disponible, o ejecuta {command} en una terminal.",
           runInTerminal: "en una terminal.",
           noProviders: "No se han detectado proveedores compatibles con OAuth.",
           login: "Iniciar sesión",
           disconnect: "Desconectar",
           managedExternally: "Gestionado externamente",
           copied: "Copiado ✓",
      +    copyCode: "Copiar código",
      +    copyFailed: "No se pudo copiar automáticamente. Selecciona el código y cópialo manualmente.",
           cli: "Copiar",
           copyCliCommand: "Copiar comando CLI (para externo / alternativa)",
           connect: "Conectar",
    • web/src/i18n/fr.ts
      @@ -447,16 +447,19 @@ export const fr: Translations = {
         oauth: {
           title: "Connexions fournisseurs (OAuth)",
           providerLogins: "Connexions fournisseurs (OAuth)",
      -    description: "{connected} sur {total} fournisseurs OAuth connectés. Les flux de connexion s'exécutent actuellement via le CLI ; cliquez sur Copier la commande et collez-la dans un terminal pour configurer.",
      +    description:
      +      "{connected} sur {total} fournisseurs OAuth connectés. Utilisez Connexion pour les flux pris en charge par le tableau de bord ; les commandes CLI restent disponibles pour une configuration externe ou de secours.",
           connected: "Connecté",
           expired: "Expiré",
      -    notConnected: "Non connecté. Exécutez {command} dans un terminal.",
      +    notConnected: "Non connecté. Utilisez Connexion si disponible, ou exécutez {command} dans un terminal.",
           runInTerminal: "dans un terminal.",
           noProviders: "Aucun fournisseur compatible OAuth détecté.",
           login: "Connexion",
           disconnect: "Déconnecter",
           managedExternally: "Géré en externe",
           copied: "Copié ✓",
      +    copyCode: "Copier le code",
      +    copyFailed: "Copie automatique impossible. Sélectionnez le code et copiez-le manuellement.",
           cli: "Copier",
           copyCliCommand: "Copier la commande CLI (pour externe / repli)",
           connect: "Connecter",
    • web/src/i18n/ga.ts
      @@ -454,16 +454,19 @@ export const ga: Translations = {
         oauth: {
           title: "Logálacha isteach soláthraí (OAuth)",
           providerLogins: "Logálacha isteach soláthraí (OAuth)",
      -    description: "{connected} as {total} soláthraí OAuth ceangailte. Reáchtáiltear sreabha logála isteach faoi láthair tríd an CLI; cliceáil Cóipeáil ordú agus greamaigh i dteirminéal chun é a shocrú.",
      +    description:
      +      "{connected} as {total} soláthraí OAuth ceangailte. Úsáid Logáil isteach le haghaidh sreabha a dtacaíonn an deais leo; tá orduithe CLI ar fáil i gcónaí do shocrú seachtrach nó cúltaca.",
           connected: "Ceangailte",
           expired: "As feidhm",
      -    notConnected: "Gan cheangal. Rith {command} i dteirminéal.",
      +    notConnected: "Gan cheangal. Úsáid Logáil isteach má tá sé ar fáil, nó rith {command} i dteirminéal.",
           runInTerminal: "i dteirminéal.",
           noProviders: "Níor aimsíodh soláthraithe a thacaíonn le OAuth.",
           login: "Logáil isteach",
           disconnect: "Dícheangail",
           managedExternally: "Bainistithe go seachtrach",
           copied: "Cóipeáilte ✓",
      +    copyCode: "Cóipeáil an cód",
      +    copyFailed: "Níorbh fhéidir cóipeáil go huathoibríoch. Roghnaigh an cód agus cóipeáil de láimh é.",
           cli: "Cóipeáil",
           copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)",
           connect: "Ceangail",
    • web/src/i18n/hu.ts
      @@ -446,16 +446,19 @@ export const hu: Translations = {
         oauth: {
           title: "Szolgáltatói bejelentkezések (OAuth)",
           providerLogins: "Szolgáltatói bejelentkezések (OAuth)",
      -    description: "{connected} / {total} OAuth-szolgáltató csatlakoztatva. A bejelentkezési folyamat jelenleg a CLI-n keresztül fut; kattintson a Parancs másolása gombra, és illessze be egy terminálba a beállításhoz.",
      +    description:
      +      "{connected} / {total} OAuth-szolgáltató csatlakoztatva. Használja a Bejelentkezés gombot az irányítópult által támogatott folyamatokhoz; a CLI-parancsok továbbra is elérhetők külső vagy tartalék beállításhoz.",
           connected: "Csatlakoztatva",
           expired: "Lejárt",
      -    notConnected: "Nincs csatlakoztatva. Futtassa a {command} parancsot egy terminálban.",
      +    notConnected: "Nincs csatlakoztatva. Használja a Bejelentkezés gombot, ha elérhető, vagy futtassa a {command} parancsot egy terminálban.",
           runInTerminal: "egy terminálban.",
           noProviders: "Nem észlelhető OAuth-képes szolgáltató.",
           login: "Bejelentkezés",
           disconnect: "Lecsatlakozás",
           managedExternally: "Külsőleg kezelt",
           copied: "Másolva ✓",
      +    copyCode: "Kód másolása",
      +    copyFailed: "Nem sikerült automatikusan másolni. Jelölje ki a kódot, és másolja kézzel.",
           cli: "Másolás",
           copyCliCommand: "CLI-parancs másolása (külső / tartalék)",
           connect: "Csatlakozás",
    • web/src/i18n/it.ts
      @@ -446,16 +446,19 @@ export const it: Translations = {
         oauth: {
           title: "Accessi provider (OAuth)",
           providerLogins: "Accessi provider (OAuth)",
      -    description: "{connected} di {total} provider OAuth connessi. I flussi di accesso vengono attualmente eseguiti tramite la CLI; clicca Copia comando e incolla in un terminale per configurare.",
      +    description:
      +      "{connected} di {total} provider OAuth connessi. Usa Accedi per i flussi supportati dalla dashboard; i comandi CLI restano disponibili per configurazioni esterne o di riserva.",
           connected: "Connesso",
           expired: "Scaduto",
      -    notConnected: "Non connesso. Esegui {command} in un terminale.",
      +    notConnected: "Non connesso. Usa Accedi se disponibile, oppure esegui {command} in un terminale.",
           runInTerminal: "in un terminale.",
           noProviders: "Nessun provider compatibile con OAuth rilevato.",
           login: "Accedi",
           disconnect: "Disconnetti",
           managedExternally: "Gestito esternamente",
           copied: "Copiato ✓",
      +    copyCode: "Copia codice",
      +    copyFailed: "Impossibile copiare automaticamente. Seleziona il codice e copialo manualmente.",
           cli: "Copia",
           copyCliCommand: "Copia comando CLI (per uso esterno / fallback)",
           connect: "Connetti",
    • web/src/i18n/ja.ts
      @@ -445,16 +445,19 @@ export const ja: Translations = {
         oauth: {
           title: "プロバイダーログイン (OAuth)",
           providerLogins: "プロバイダーログイン (OAuth)",
      -    description: "{connected} / {total} OAuth プロバイダーが接続されています。ログインフローは現在 CLI 経由で実行されます。「コマンドをコピー」をクリックして、ターミナルに貼り付けてセットアップしてください。",
      +    description:
      +      "{connected} / {total} OAuth プロバイダーが接続されています。ダッシュボード対応のフローには「ログイン」を使用してください。外部またはフォールバック用のセットアップには引き続き CLI コマンドを利用できます。",
           connected: "接続済み",
           expired: "期限切れ",
      -    notConnected: "未接続です。ターミナルで {command} を実行してください。",
      +    notConnected: "未接続です。可能な場合は「ログイン」を使用するか、ターミナルで {command} を実行してください。",
           runInTerminal: "ターミナルで実行してください。",
           noProviders: "OAuth 対応プロバイダーは検出されませんでした。",
           login: "ログイン",
           disconnect: "切断",
           managedExternally: "外部で管理",
           copied: "コピーしました ✓",
      +    copyCode: "コードをコピー",
      +    copyFailed: "自動でコピーできませんでした。コードを選択して手動でコピーしてください。",
           cli: "コピー",
           copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)",
           connect: "接続",
    • web/src/i18n/ko.ts
      @@ -445,16 +445,19 @@ export const ko: Translations = {
         oauth: {
           title: "제공자 로그인 (OAuth)",
           providerLogins: "제공자 로그인 (OAuth)",
      -    description: "{connected}/{total} OAuth 제공자가 연결되었습니다. 로그인 흐름은 현재 CLI를 통해 실행됩니다. 명령 복사를 클릭하고 터미널에 붙여넣어 설정하세요.",
      +    description:
      +      "{connected}/{total} OAuth 제공자가 연결되었습니다. 대시보드에서 지원되는 흐름에는 로그인을 사용하세요. 외부 또는 대체 설정에는 CLI 명령을 계속 사용할 수 있습니다.",
           connected: "연결됨",
           expired: "만료됨",
      -    notConnected: "연결되지 않음. 터미널에서 {command}을(를) 실행하세요.",
      +    notConnected: "연결되지 않음. 가능하면 로그인을 사용하거나 터미널에서 {command}을(를) 실행하세요.",
           runInTerminal: "터미널에서.",
           noProviders: "OAuth를 지원하는 제공자가 감지되지 않았습니다.",
           login: "로그인",
           disconnect: "연결 해제",
           managedExternally: "외부에서 관리됨",
           copied: "복사됨 ✓",
      +    copyCode: "코드 복사",
      +    copyFailed: "자동으로 복사할 수 없습니다. 코드를 선택하여 직접 복사하세요.",
           cli: "복사",
           copyCliCommand: "CLI 명령 복사 (외부 / 대체용)",
           connect: "연결",
    • web/src/i18n/pt.ts
      @@ -447,16 +447,19 @@ export const pt: Translations = {
         oauth: {
           title: "Inícios de sessão de fornecedor (OAuth)",
           providerLogins: "Inícios de sessão de fornecedor (OAuth)",
      -    description: "{connected} de {total} fornecedores OAuth ligados. Os fluxos de início de sessão são executados via CLI; clique em Copiar comando e cole num terminal para configurar.",
      +    description:
      +      "{connected} de {total} fornecedores OAuth ligados. Use Iniciar sessão para fluxos suportados pelo painel; os comandos CLI continuam disponíveis para configuração externa ou de recurso.",
           connected: "Ligado",
           expired: "Expirado",
      -    notConnected: "Não ligado. Execute {command} num terminal.",
      +    notConnected: "Não ligado. Use Iniciar sessão quando disponível, ou execute {command} num terminal.",
           runInTerminal: "num terminal.",
           noProviders: "Não foram detetados fornecedores compatíveis com OAuth.",
           login: "Iniciar sessão",
           disconnect: "Desligar",
           managedExternally: "Gerido externamente",
           copied: "Copiado ✓",
      +    copyCode: "Copiar código",
      +    copyFailed: "Não foi possível copiar automaticamente. Selecione o código e copie-o manualmente.",
           cli: "Copiar",
           copyCliCommand: "Copiar comando CLI (para externo / fallback)",
           connect: "Ligar",
    • web/src/i18n/ru.ts
      @@ -446,16 +446,19 @@ export const ru: Translations = {
         oauth: {
           title: "Входы провайдеров (OAuth)",
           providerLogins: "Входы провайдеров (OAuth)",
      -    description: "Подключено {connected} из {total} OAuth-провайдеров. Процесс входа в настоящее время выполняется через CLI; нажмите «Скопировать команду» и вставьте в терминал для настройки.",
      +    description:
      +      "Подключено {connected} из {total} OAuth-провайдеров. Используйте «Войти» для процессов, поддерживаемых панелью; команды CLI остаются доступными для внешней или резервной настройки.",
           connected: "Подключено",
           expired: "Срок истёк",
      -    notConnected: "Не подключено. Выполните {command} в терминале.",
      +    notConnected: "Не подключено. Используйте «Войти», если доступно, или выполните {command} в терминале.",
           runInTerminal: "в терминале.",
           noProviders: "OAuth-совместимые провайдеры не обнаружены.",
           login: "Войти",
           disconnect: "Отключить",
           managedExternally: "Управляется извне",
           copied: "Скопировано ✓",
      +    copyCode: "Скопировать код",
      +    copyFailed: "Не удалось скопировать автоматически. Выделите код и скопируйте его вручную.",
           cli: "Копировать",
           copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)",
           connect: "Подключить",
    • web/src/i18n/tr.ts
      @@ -446,16 +446,19 @@ export const tr: Translations = {
         oauth: {
           title: "Sağlayıcı Girişleri (OAuth)",
           providerLogins: "Sağlayıcı Girişleri (OAuth)",
      -    description: "{connected}/{total} OAuth sağlayıcısı bağlandı. Giriş akışları şu anda CLI üzerinden çalışır; Komutu kopyala'ya tıklayın ve kurmak için bir terminale yapıştırın.",
      +    description:
      +      "{connected}/{total} OAuth sağlayıcısı bağlandı. Panel destekli akışlar için Giriş'i kullanın; CLI komutları harici veya yedek kurulum için kullanılabilir.",
           connected: "Bağlandı",
           expired: "Süresi doldu",
      -    notConnected: "Bağlı değil. Bir terminalde {command} komutunu çalıştırın.",
      +    notConnected: "Bağlı değil. Mümkünse Giriş'i kullanın veya bir terminalde {command} komutunu çalıştırın.",
           runInTerminal: "bir terminalde.",
           noProviders: "OAuth uyumlu sağlayıcı algılanmadı.",
           login: "Giriş",
           disconnect: "Bağlantıyı kes",
           managedExternally: "Harici olarak yönetiliyor",
           copied: "Kopyalandı ✓",
      +    copyCode: "Kodu kopyala",
      +    copyFailed: "Otomatik olarak kopyalanamadı. Kodu seçip elle kopyalayın.",
           cli: "Kopyala",
           copyCliCommand: "CLI komutunu kopyala (harici / yedek için)",
           connect: "Bağlan",
    • web/src/i18n/types.ts
      @@ -530,6 +530,8 @@ export interface Translations {
           disconnect: string;
           managedExternally: string;
           copied: string;
      +    copyCode: string;
      +    copyFailed: string;
           cli: string;
           copyCliCommand: string;
           connect: string;
    • web/src/i18n/uk.ts
      @@ -447,16 +447,19 @@ export const uk: Translations = {
         oauth: {
           title: "Входи постачальників (OAuth)",
           providerLogins: "Входи постачальників (OAuth)",
      -    description: "Підключено {connected} з {total} постачальників OAuth. Процеси входу наразі виконуються через CLI; натисніть «Скопіювати команду» та вставте у термінал, щоб налаштувати.",
      +    description:
      +      "Підключено {connected} з {total} постачальників OAuth. Використовуйте «Увійти» для процесів, підтримуваних панеллю; команди CLI залишаються доступними для зовнішнього або резервного налаштування.",
           connected: "Підключено",
           expired: "Прострочено",
      -    notConnected: "Не підключено. Виконайте {command} у терміналі.",
      +    notConnected: "Не підключено. Використовуйте «Увійти», якщо доступно, або виконайте {command} у терміналі.",
           runInTerminal: "у терміналі.",
           noProviders: "Не виявлено постачальників із підтримкою OAuth.",
           login: "Увійти",
           disconnect: "Відключити",
           managedExternally: "Керується ззовні",
           copied: "Скопійовано ✓",
      +    copyCode: "Скопіювати код",
      +    copyFailed: "Не вдалося скопіювати автоматично. Виділіть код і скопіюйте його вручну.",
           cli: "Копіювати",
           copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)",
           connect: "Підключити",
    • web/src/i18n/zh-hant.ts
      @@ -445,16 +445,19 @@ export const zhHant: Translations = {
         oauth: {
           title: "提供者登入(OAuth)",
           providerLogins: "提供者登入(OAuth)",
      -    description: "已連線 {connected}/{total} 個 OAuth 提供者。登入流程目前透過 CLI 執行;請點擊「複製指令」並貼到終端機完成設定。",
      +    description:
      +      "已連線 {connected}/{total} 個 OAuth 提供者。儀表板支援的流程請使用「登入」;CLI 指令仍可用於外部或備用設定。",
           connected: "已連線",
           expired: "已過期",
      -    notConnected: "未連線。請在終端機執行 {command}。",
      +    notConnected: "未連線。可用時請使用「登入」,或在終端機執行 {command}。",
           runInTerminal: "於終端機。",
           noProviders: "未偵測到支援 OAuth 的提供者。",
           login: "登入",
           disconnect: "中斷連線",
           managedExternally: "由外部管理",
           copied: "已複製 ✓",
      +    copyCode: "複製代碼",
      +    copyFailed: "無法自動複製。請選取代碼並手動複製。",
           cli: "複製",
           copyCliCommand: "複製 CLI 指令(外部 / 備援用)",
           connect: "連線",
    • web/src/i18n/zh.ts
      @@ -440,16 +440,19 @@ export const zh: Translations = {
         oauth: {
           title: "提供商登录(OAuth)",
           providerLogins: "提供商登录(OAuth)",
      -    description: "已连接 {connected}/{total} 个 OAuth 提供商。登录流程目前通过 CLI 运行;点击「复制命令」并粘贴到终端中进行设置。",
      +    description:
      +      "已连接 {connected}/{total} 个 OAuth 提供商。仪表板支持的流程请使用「登录」;CLI 命令仍可用于外部或备用设置。",
           connected: "已连接",
           expired: "已过期",
      -    notConnected: "未连接。在终端中运行 {command}。",
      +    notConnected: "未连接。可用时请使用「登录」,或在终端中运行 {command}。",
           runInTerminal: "在终端中。",
           noProviders: "未检测到支持 OAuth 的提供商。",
           login: "登录",
           disconnect: "断开连接",
           managedExternally: "外部管理",
           copied: "已复制 ✓",
      +    copyCode: "复制代码",
      +    copyFailed: "无法自动复制。请选中代码并手动复制。",
           cli: "复制",
           copyCliCommand: "复制 CLI 命令(用于外部/备用方式)",
           connect: "连接",
    • web/src/lib/api.test.ts
      @@ -2,21 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest";
       
       import { api } from "./api";
       
      +const SESSION_HEADER = "X-Hermes-Session-Token";
      +
       afterEach(() => {
         vi.restoreAllMocks();
         vi.unstubAllGlobals();
       });
       
      +function jsonFetchMock(body: unknown = { ok: true }) {
      +  return vi.fn<typeof fetch>(
      +    async () =>
      +      new Response(JSON.stringify(body), {
      +        headers: { "Content-Type": "application/json" },
      +        status: 200,
      +      }),
      +  );
      +}
      +
       describe("api.getModelOptions", () => {
         it("requests a live model refresh when asked", async () => {
           vi.stubGlobal("window", {});
       
      -    const fetchMock = vi.fn(async () =>
      -      new Response(JSON.stringify({ providers: [] }), {
      -        headers: { "Content-Type": "application/json" },
      -        status: 200,
      -      }),
      -    );
      +    const fetchMock = jsonFetchMock({ providers: [] });
           vi.stubGlobal("fetch", fetchMock);
       
           await api.getModelOptions({ refresh: true });
      @@ -30,12 +37,7 @@ describe("api.getModelOptions", () => {
         it("keeps explicit profile scoping when refreshing", async () => {
           vi.stubGlobal("window", {});
       
      -    const fetchMock = vi.fn(async () =>
      -      new Response(JSON.stringify({ providers: [] }), {
      -        headers: { "Content-Type": "application/json" },
      -        status: 200,
      -      }),
      -    );
      +    const fetchMock = jsonFetchMock({ providers: [] });
           vi.stubGlobal("fetch", fetchMock);
       
           await api.getModelOptions({ profile: "default", refresh: true });
      @@ -46,3 +48,59 @@ describe("api.getModelOptions", () => {
           );
         });
       });
      +
      +describe("api OAuth helpers", () => {
      +  it("starts OAuth login in gated mode without requiring an injected session token", async () => {
      +    vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
      +    const fetchMock = jsonFetchMock({
      +      flow: "device_code",
      +      session_id: "oauth-session",
      +    });
      +    vi.stubGlobal("fetch", fetchMock);
      +
      +    await api.startOAuthLogin("openai-codex");
      +
      +    expect(fetchMock).toHaveBeenCalledWith(
      +      "/api/providers/oauth/openai-codex/start",
      +      expect.objectContaining({
      +        body: "{}",
      +        credentials: "[redacted:secret-assignment]",
      +        method: "POST",
      +      }),
      +    );
      +    const headers = fetchMock.mock.calls[0][1]?.headers as Headers;
      +    expect(headers.get("Content-Type")).toBe("application/json");
      +    expect(headers.has(SESSION_HEADER)).toBe(false);
      +  });
      +
      +  it("still sends the injected session token for OAuth login in loopback mode", async () => {
      +    vi.stubGlobal("window", { __HERMES_SESSION_TOKEN__: "[redacted:secret-assignment]" });
      +    const fetchMock = jsonFetchMock({
      +      flow: "device_code",
      +      session_id: "oauth-session",
      +    });
      +    vi.stubGlobal("fetch", fetchMock);
      +
      +    await api.startOAuthLogin("openai-codex");
      +
      +    const headers = fetchMock.mock.calls[0][1]?.headers as Headers;
      +    expect(headers.get(SESSION_HEADER)).toBe("loopback-token");
      +  });
      +
      +  it("runs provider auth mutations in gated mode via cookie auth", async () => {
      +    vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
      +    const fetchMock = jsonFetchMock({ ok: true });
      +    vi.stubGlobal("fetch", fetchMock);
      +
      +    await api.disconnectOAuthProvider("anthropic");
      +    await api.submitOAuthCode("anthropic", "oauth-session", "code-123");
      +    await api.cancelOAuthSession("oauth-session");
      +    await api.revealEnvVar("OPENAI_API_KEY");
      +
      +    for (const call of fetchMock.mock.calls) {
      +      const init = call[1] as RequestInit;
      +      expect(init.credentials).toBe("include");
      +      expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false);
      +    }
      +  });
      +});
    • web/src/lib/api.ts
      @@ -34,7 +34,6 @@ declare global {
           __HERMES_AUTH_REQUIRED__?: boolean;
         }
       }
      -let _sessionToken: string | null = null;
       const SESSION_HEADER = "X-Hermes-Session-Token";
       
       function setSessionHeader(headers: Headers, token: [redacted:secret-assignment] void {
      @@ -197,16 +196,6 @@ function pluginPath(name: string): string {
         return name.split("/").map(encodeURIComponent).join("/");
       }
       
      -async function getSessionToken(): Promise<string> {
      -  if (_sessionToken) return _sessionToken;
      -  const injected = window.__HERMES_SESSION_TOKEN__;
      -  if (injected) {
      -    _sessionToken = [redacted:secret-assignment]
      -    return _sessionToken;
      -  }
      -  throw new Error("Session token not available — page must be served by the Hermes dashboard server");
      -}
      -
       /**
        * Fetch a single-use ticket for a WebSocket upgrade in gated mode.
        *
      @@ -553,17 +542,12 @@ export const api = {
             headers: { "Content-Type": "application/json" },
             body: JSON.stringify({ key }),
           }),
      -  revealEnvVar: async (key: string) => {
      -    const token = await getSessionToken();
      -    return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
      +  revealEnvVar: (key: string) =>
      +    fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
             method: "POST",
      -      headers: {
      -        "Content-Type": "application/json",
      -        [SESSION_HEADER]: token,
      -      },
      +      headers: { "Content-Type": "application/json" },
             body: JSON.stringify({ key }),
      -    });
      -  },
      +    }),
       
         // Cron jobs
         getCronJobs: (profile = "all") =>
      @@ -788,58 +772,42 @@ export const api = {
         // OAuth provider management
         getOAuthProviders: () =>
           fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
      -  disconnectOAuthProvider: async (providerId: string) => {
      -    const token = await getSessionToken();
      -    return fetchJSON<{ ok: boolean; provider: string }>(
      +  disconnectOAuthProvider: (providerId: string) =>
      +    fetchJSON<{ ok: boolean; provider: string }>(
             `/api/providers/oauth/${encodeURIComponent(providerId)}`,
             {
               method: "DELETE",
      -        headers: { [SESSION_HEADER]: token },
             },
      -    );
      -  },
      -  startOAuthLogin: async (providerId: string) => {
      -    const token = await getSessionToken();
      -    return fetchJSON<OAuthStartResponse>(
      +    ),
      +  startOAuthLogin: (providerId: string) =>
      +    fetchJSON<OAuthStartResponse>(
             `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
             {
               method: "POST",
      -        headers: {
      -          "Content-Type": "application/json",
      -          [SESSION_HEADER]: token,
      -        },
      +        headers: { "Content-Type": "application/json" },
               body: "{}",
             },
      -    );
      -  },
      -  submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
      -    const token = await getSessionToken();
      -    return fetchJSON<OAuthSubmitResponse>(
      +    ),
      +  submitOAuthCode: (providerId: string, sessionId: string, code: string) =>
      +    fetchJSON<OAuthSubmitResponse>(
             `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
             {
               method: "POST",
      -        headers: {
      -          "Content-Type": "application/json",
      -          [SESSION_HEADER]: token,
      -        },
      +        headers: { "Content-Type": "application/json" },
               body: JSON.stringify({ session_id: sessionId, code }),
             },
      -    );
      -  },
      +    ),
         pollOAuthSession: (providerId: string, sessionId: string) =>
           fetchJSON<OAuthPollResponse>(
             `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,
           ),
      -  cancelOAuthSession: async (sessionId: string) => {
      -    const token = await getSessionToken();
      -    return fetchJSON<{ ok: boolean }>(
      +  cancelOAuthSession: (sessionId: string) =>
      +    fetchJSON<{ ok: boolean }>(
             `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
             {
               method: "DELETE",
      -        headers: { [SESSION_HEADER]: token },
             },
      -    );
      -  },
      +    ),
       
         // Messaging platforms (gateway channels)
         getMessagingPlatforms: () =>
    • web/src/lib/clipboard.test.ts
      @@ -0,0 +1,114 @@
      +import { afterEach, describe, expect, it, vi } from "vitest";
      +import { copyTextToClipboard } from "./clipboard";
      +
      +const originalNavigator = globalThis.navigator;
      +const originalDocument = globalThis.document;
      +const originalWindow = globalThis.window;
      +
      +function setGlobal<K extends keyof typeof globalThis>(
      +  key: K,
      +  value: (typeof globalThis)[K] | undefined,
      +) {
      +  Object.defineProperty(globalThis, key, {
      +    configurable: true,
      +    value,
      +  });
      +}
      +
      +afterEach(() => {
      +  setGlobal("navigator", originalNavigator);
      +  setGlobal("document", originalDocument);
      +  setGlobal("window", originalWindow);
      +  vi.restoreAllMocks();
      +});
      +
      +describe("copyTextToClipboard", () => {
      +  it("uses navigator.clipboard when available", async () => {
      +    const writeText = vi.fn().mockResolvedValue(undefined);
      +    setGlobal(
      +      "navigator",
      +      { clipboard: { writeText } } as unknown as Navigator,
      +    );
      +    setGlobal("document", undefined);
      +
      +    await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
      +    expect(writeText).toHaveBeenCalledWith("CODEX-1234");
      +  });
      +
      +  it("falls back to selection copy when Clipboard API fails", async () => {
      +    const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
      +    const appendChild = vi.fn();
      +    const removeChild = vi.fn();
      +    const execCommand = vi.fn().mockReturnValue(true);
      +    const textarea = {
      +      focus: vi.fn(),
      +      select: vi.fn(),
      +      setAttribute: vi.fn(),
      +      setSelectionRange: vi.fn(),
      +      style: {},
      +      value: "",
      +    } as unknown as HTMLTextAreaElement;
      +
      +    setGlobal(
      +      "navigator",
      +      { clipboard: { writeText } } as unknown as Navigator,
      +    );
      +    setGlobal("document", {
      +      body: { appendChild, removeChild },
      +      createElement: vi.fn(() => textarea),
      +      execCommand,
      +      getSelection: vi.fn(() => null),
      +    } as unknown as Document);
      +
      +    await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
      +
      +    expect(writeText).toHaveBeenCalledWith("CODEX-1234");
      +    expect(textarea.value).toBe("CODEX-1234");
      +    expect(appendChild).toHaveBeenCalledWith(textarea);
      +    expect(textarea.select).toHaveBeenCalled();
      +    expect(execCommand).toHaveBeenCalledWith("copy");
      +    expect(removeChild).toHaveBeenCalledWith(textarea);
      +  });
      +
      +  it("uses selection copy directly in insecure browser contexts", async () => {
      +    const writeText = vi.fn().mockResolvedValue(undefined);
      +    const appendChild = vi.fn();
      +    const removeChild = vi.fn();
      +    const execCommand = vi.fn().mockReturnValue(true);
      +    const textarea = {
      +      focus: vi.fn(),
      +      select: vi.fn(),
      +      setAttribute: vi.fn(),
      +      setSelectionRange: vi.fn(),
      +      style: {},
      +      value: "",
      +    } as unknown as HTMLTextAreaElement;
      +
      +    setGlobal(
      +      "navigator",
      +      { clipboard: { writeText } } as unknown as Navigator,
      +    );
      +    setGlobal(
      +      "window",
      +      { isSecureContext: false } as unknown as Window & typeof globalThis,
      +    );
      +    setGlobal("document", {
      +      body: { appendChild, removeChild },
      +      createElement: vi.fn(() => textarea),
      +      execCommand,
      +      getSelection: vi.fn(() => null),
      +    } as unknown as Document);
      +
      +    await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
      +
      +    expect(writeText).not.toHaveBeenCalled();
      +    expect(execCommand).toHaveBeenCalledWith("copy");
      +  });
      +
      +  it("returns false when no copy mechanism is available", async () => {
      +    setGlobal("navigator", {} as Navigator);
      +    setGlobal("document", undefined);
      +
      +    await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(false);
      +  });
      +});
    • web/src/lib/clipboard.ts
      @@ -0,0 +1,56 @@
      +export async function copyTextToClipboard(text: string): Promise<boolean> {
      +  const clipboard =
      +    typeof navigator === "undefined" ? undefined : navigator.clipboard;
      +  const secureContext =
      +    typeof window === "undefined" ? true : window.isSecureContext;
      +  if (secureContext && clipboard?.writeText) {
      +    try {
      +      await clipboard.writeText(text);
      +      return true;
      +    } catch {
      +      // Fall through to the selection-based copy path below.
      +    }
      +  }
      +
      +  if (typeof document === "undefined") {
      +    return false;
      +  }
      +
      +  const textarea = document.createElement("textarea");
      +  textarea.value = text;
      +  textarea.setAttribute("readonly", "");
      +  textarea.style.position = "fixed";
      +  textarea.style.top = "-1000px";
      +  textarea.style.left = "-1000px";
      +  textarea.style.opacity = "0";
      +  document.body.appendChild(textarea);
      +
      +  const selection = document.getSelection();
      +  const ranges: Range[] = [];
      +  if (selection) {
      +    for (let i = 0; i < selection.rangeCount; i += 1) {
      +      ranges.push(selection.getRangeAt(i));
      +    }
      +  }
      +
      +  textarea.focus();
      +  textarea.select();
      +  textarea.setSelectionRange(0, textarea.value.length);
      +
      +  let copied = false;
      +  try {
      +    copied = document.execCommand("copy");
      +  } catch {
      +    copied = false;
      +  } finally {
      +    document.body.removeChild(textarea);
      +    if (selection) {
      +      selection.removeAllRanges();
      +      for (const range of ranges) {
      +        selection.addRange(range);
      +      }
      +    }
      +  }
      +
      +  return copied;
      +}

22 PRs closed without merging

From the close comments: done elsewhere 12 · superseded by another PR 5 · duplicate 3 · other 2

  • #55133 fix(dashboard): skip auto-SSO for password-only auth providers (500 on basic-auth-only dashboard) · done elsewhere → 3e24b16, #54887
  • #55235 fix(dashboard): skip auto-SSO redirect for password-only providers (#55130) · superseded by another PR → #54887
  • #55330 fix(cli): skip auto-SSO redirect for password-only auth providers · done elsewhere → 3e24b16
  • #55575 fix(dashboard): skip auto-sso for password providers · superseded by another PR → 4734071, #61330
  • #55988 fix(dashboard): guard auth_login against password-only providers to prevent NotImplementedError crash · other → 3e24b16
Show 17 more
  • #55993 fix(dashboard): handle NotImplementedError on logout for password-only providers (#55985) · done elsewhere → 3e24b16
  • #56082 fix: skip auto-SSO redirect for password-only auth providers · done elsewhere → 3e24b16, #54887
  • #56191 fix(dashboard): skip auto-SSO redirect for password-only providers · superseded by another PR → #54887, #56191, #55988
  • #56627 fix(dashboard): redirect password-only auth to /login instead of /auth/login · done elsewhere → 3e24b16, #54887
  • #57214 fix(dashboard-auth): skip auto-SSO for password-only provider (500 on login) · done elsewhere → 3e24b16, #54887
  • #57513 fix(gateway): use getattr for __cause__ in _is_transient_network_error · other → #57294, #57298, #57299
  • #57959 fix: dashboard basic auth (password-only provider) no longer crashes on first page load (#57868) · duplicate → #54887, #55130, #54846, #56886
  • #58007 fix(auth): skip auto-SSO redirect for password-only providers (#57868) · done elsewhere → 3e24b16, 4734071, #61330
  • #58247 fix(dashboard-auth): skip password-only providers in auto-SSO redirect (#58237) · done elsewhere → 3e24b16, #54887
  • #58315 fix(dashboard-auth): prevent 500 crash on GET /auth/login?provider=basic (#58166) · duplicate
  • #58926 fix(dashboard): skip auto-SSO redirect for password-only providers · superseded by another PR → #54887
  • #59643 fix(dashboard-auth): skip auto-SSO redirect for password-only providers · done elsewhere → 3e24b16, #54887
  • #60123 fix(dashboard): skip auto-SSO for password-only providers (BasicAuthProvider) · duplicate → #54887
  • #60247 fix(dashboard-auth): route password-capable providers to /login instead of /auth/login · done elsewhere → #61330
  • #60978 fix(#55985): guard start_login() call — BasicAuthProvider has no OAuth redirect flow · done elsewhere → 3e24b16
  • #61342 fix(dashboard-auth): redirect password providers to /login instead of 500 · superseded by another PR
  • #62786 fix(dashboard): render password form for supports_password providers in OAuthLoginModal · done elsewhere → ed9e8ba, #62782
Closed issues · 25
  • Closed#55130Dashboard 500s on every page load when basic (password) auth is the only provider — auto-SSO calls start_login() on a password-only provider
  • Closed#55498Dashboard: BasicAuthProvider crashes with NotImplementedError on /auth/login
  • Closed#55985Dashboard logout crashes container via NotImplementedError in BasicAuthProvider
  • Closed#56067Dashboard 500 on password-only auth provider: _auto_sso_response redirects to /auth/login instead of /login
  • Closed#56130Dashboard returns HTTP 500 on non-loopback bind with basic auth (BasicAuthProvider auto-redirect crash)
  • Closed#57211Dashboard 500s on login when basic_auth is the only provider (auto-SSO calls start_login() on a password provider)
  • Closed#57220Dashboard returns HTTP 500 on first visit when basic is the only auth provider (auto-SSO redirects into OAuth-only route)
  • Closed#57294fix(dashboard): password-only providers crash on /auth/login route
  • Closed#57482LAN/local dashboard access requires password after June 2026 auth hardening
  • Closed#57868Dashboard basic auth (password-only provider) crashes with 500 on first page load
  • Closed#58020Dashboard /auth/login?provider=basic returns 500 — BasicAuthProvider.start_login() raises NotImplementedError
  • Closed#58166fix(dashboard-auth): GET /auth/login?provider=basic returns HTTP 500 (NotImplementedError in BasicAuthProvider.start_login)
  • Closed#58237[Bug]: Dashboard auto-SSO redirect 500s when only basic auth provider is configured
  • Closed#58626Dashboard: BasicAuthProvider triggers NotImplementedError when binding to 0.0.0.0
  • Closed#58810Dashboard: 500 on unauthenticated `/` when the only auth provider is password-only (auto-SSO redirects into OAuth start_login)
  • Closed#58921500 Internal Server Error when BasicAuthProvider is the only auth provider (auto-SSO crashes on password-only provider)
  • Closed#59748v0.18.0: Dashboard /auth/login crashes when BasicAuth is sole session provider
  • Closed#60105Dashboard returns 500 on / when the only registered auth provider is password-only (BasicAuthProvider)
  • Closed#60660Dashboard: root '/' returns 500 when sole auth provider is password-only (basic)
  • Closed#61341dashboard auth: /auth/login?provider=basic returns 500 for password-only providers
  • Closed#62463Dashboard auto-SSO redirect crashes (500) when the sole configured provider is BasicAuthProvider
  • Closed#62782Dashboard Basic Auth login page triggers OAuth redirect instead of rendering password form (500 Internal Server Error)
  • Closed#64424Dashboard auto-SSO redirect breaks for basic_auth (password-only) providers
  • Closed#65327BasicAuthProvider 缺少 supports_session=False 导致 Dashboard 自动 SSO 跳转时 500 错误
  • Closed#67460Dashboard basic auth: unauthenticated visit 500s (single-provider redirect goes to the OAuth kick-off route)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.07: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
dashboard
Merged PRs
#61330
Closed issues
25
Attempts
22 PRs closed without merging
Fix link checked
9 Jul 2026

For your agent

The same facts, typed.