{"repo":"NousResearch/hermes-agent","public_id":"55130","display_label":"Dashboard 500 when basic auth is sole provider","summary":"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.","subsystem":"dashboard","counts":{"members":48,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":25,"merged_prs":1,"attempt_prs":22},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":61330},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-09T06:51:17+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07085106382978716},"fix_edges":[{"issue_number":55130,"pr_number":61330,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":57868,"pr_number":61330,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":58166,"pr_number":61330,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":55130,"title":"Dashboard 500s on every page load when basic (password) auth is the only provider — auto-SSO calls start_login() on a password-only provider","url":null,"state":"closed"},{"kind":"issue","number":55498,"title":"Dashboard: BasicAuthProvider crashes with NotImplementedError on /auth/login","url":null,"state":"closed"},{"kind":"issue","number":55985,"title":"Dashboard logout crashes container via NotImplementedError in BasicAuthProvider","url":null,"state":"closed"},{"kind":"issue","number":56067,"title":"Dashboard 500 on password-only auth provider: _auto_sso_response redirects to /auth/login instead of /login","url":null,"state":"closed"},{"kind":"issue","number":56130,"title":"Dashboard returns HTTP 500 on non-loopback bind with basic auth (BasicAuthProvider auto-redirect crash)","url":null,"state":"closed"},{"kind":"issue","number":57211,"title":"Dashboard 500s on login when basic_auth is the only provider (auto-SSO calls start_login() on a password provider)","url":null,"state":"closed"},{"kind":"issue","number":57220,"title":"Dashboard returns HTTP 500 on first visit when basic is the only auth provider (auto-SSO redirects into OAuth-only route)","url":null,"state":"closed"},{"kind":"issue","number":57294,"title":"fix(dashboard): password-only providers crash on /auth/login route","url":null,"state":"closed"},{"kind":"issue","number":57482,"title":"LAN/local dashboard access requires password after June 2026 auth hardening","url":null,"state":"closed"},{"kind":"issue","number":57868,"title":"Dashboard basic auth (password-only provider) crashes with 500 on first page load","url":null,"state":"closed"},{"kind":"issue","number":58020,"title":"Dashboard /auth/login?provider=basic returns 500 — BasicAuthProvider.start_login() raises NotImplementedError","url":null,"state":"closed"},{"kind":"issue","number":58166,"title":"fix(dashboard-auth): GET /auth/login?provider=basic returns HTTP 500 (NotImplementedError in BasicAuthProvider.start_login)","url":null,"state":"closed"},{"kind":"issue","number":58237,"title":"[Bug]: Dashboard auto-SSO redirect 500s when only basic auth provider is configured","url":null,"state":"closed"},{"kind":"issue","number":58626,"title":"Dashboard: BasicAuthProvider triggers NotImplementedError when binding to 0.0.0.0","url":null,"state":"closed"},{"kind":"issue","number":58810,"title":"Dashboard: 500 on unauthenticated `/` when the only auth provider is password-only (auto-SSO redirects into OAuth start_login)","url":null,"state":"closed"},{"kind":"issue","number":58921,"title":"500 Internal Server Error when BasicAuthProvider is the only auth provider (auto-SSO crashes on password-only provider)","url":null,"state":"closed"},{"kind":"issue","number":59748,"title":"v0.18.0: Dashboard /auth/login crashes when BasicAuth is sole session provider","url":null,"state":"closed"},{"kind":"issue","number":60105,"title":"Dashboard returns 500 on / when the only registered auth provider is password-only (BasicAuthProvider)","url":null,"state":"closed"},{"kind":"issue","number":60660,"title":"Dashboard: root '/' returns 500 when sole auth provider is password-only (basic)","url":null,"state":"closed"},{"kind":"issue","number":61341,"title":"dashboard auth: /auth/login?provider=basic returns 500 for password-only providers","url":null,"state":"closed"},{"kind":"issue","number":62463,"title":"Dashboard auto-SSO redirect crashes (500) when the sole configured provider is BasicAuthProvider","url":null,"state":"closed"},{"kind":"issue","number":62782,"title":"Dashboard Basic Auth login page triggers OAuth redirect instead of rendering password form (500 Internal Server Error)","url":null,"state":"closed"},{"kind":"issue","number":64424,"title":"Dashboard auto-SSO redirect breaks for basic_auth (password-only) providers","url":null,"state":"closed"},{"kind":"issue","number":65327,"title":"BasicAuthProvider 缺少 supports_session=False 导致 Dashboard 自动 SSO 跳转时 500 错误","url":null,"state":"closed"},{"kind":"issue","number":67460,"title":"Dashboard basic auth: unauthenticated visit 500s (single-provider redirect goes to the OAuth kick-off route)","url":null,"state":"closed"},{"kind":"pr","number":55133,"title":"fix(dashboard): skip auto-SSO for password-only auth providers (500 on basic-auth-only dashboard)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0},{"kind":"pr","value":"#54887","number":54887}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":55235,"title":"fix(dashboard): skip auto-SSO redirect for password-only providers (#55130)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54887","number":54887},{"kind":"pr","value":"#54887","number":54887}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":55330,"title":"fix(cli): skip auto-SSO redirect for password-only auth providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":55575,"title":"fix(dashboard): skip auto-sso for password providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"473407174bf304a083df166e40173503ea9eee89","number":0},{"kind":"pr","value":"#61330","number":61330}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":55988,"title":"fix(dashboard): guard auth_login against password-only providers to prevent NotImplementedError crash","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":55993,"title":"fix(dashboard): handle NotImplementedError on logout for password-only providers (#55985)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f5","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":56082,"title":"fix: skip auto-SSO redirect for password-only auth providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0},{"kind":"pr","value":"#54887","number":54887}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":56191,"title":"fix(dashboard): skip auto-SSO redirect for password-only providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54887","number":54887},{"kind":"pr","value":"#54887","number":54887},{"kind":"pr","value":"#56191","number":56191},{"kind":"pr","value":"#54887","number":54887},{"kind":"pr","value":"#54887","number":54887},{"kind":"pr","value":"#55988","number":55988}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":56627,"title":"fix(dashboard): redirect password-only auth to /login instead of /auth/login","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0},{"kind":"pr","value":"#54887","number":54887}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":57214,"title":"fix(dashboard-auth): skip auto-SSO for password-only provider (500 on login)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0},{"kind":"pr","value":"#54887","number":54887}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":57513,"title":"fix(gateway): use getattr for __cause__ in _is_transient_network_error","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57294","number":57294},{"kind":"pr","value":"#57298","number":57298},{"kind":"pr","value":"#57299","number":57299}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":57959,"title":"fix: dashboard basic auth (password-only provider) no longer crashes on first page load (#57868)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54887","number":54887},{"kind":"pr","value":"#54887","number":54887},{"kind":"pr","value":"#55130","number":55130},{"kind":"pr","value":"#54846","number":54846},{"kind":"pr","value":"#56886","number":56886},{"kind":"pr","value":"#54887","number":54887},{"kind":"pr","value":"#56886","number":56886}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":58007,"title":"fix(auth): skip auto-SSO redirect for password-only providers (#57868)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0},{"kind":"commit","value":"473407174bf304a083df166e40173503ea9eee89","number":0},{"kind":"pr","value":"#61330","number":61330}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":58247,"title":"fix(dashboard-auth): skip password-only providers in auto-SSO redirect (#58237)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0},{"kind":"pr","value":"#54887","number":54887}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":58315,"title":"fix(dashboard-auth): prevent 500 crash on GET /auth/login?provider=basic (#58166)","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":58926,"title":"fix(dashboard): skip auto-SSO redirect for password-only providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54887","number":54887},{"kind":"pr","value":"#54887","number":54887}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":59643,"title":"fix(dashboard-auth): skip auto-SSO redirect for password-only providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0},{"kind":"pr","value":"#54887","number":54887}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":60123,"title":"fix(dashboard): skip auto-SSO for password-only providers (BasicAuthProvider)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54887","number":54887},{"kind":"pr","value":"#54887","number":54887}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":60247,"title":"fix(dashboard-auth): route password-capable providers to /login instead of /auth/login","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61330","number":61330}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":60978,"title":"fix(#55985): guard start_login() call — BasicAuthProvider has no OAuth redirect flow","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":61330,"title":"fix(dashboard): support mobile/hosted OpenAI OAuth login (salvage #61281)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","web/src/i18n/types.ts","web/src/i18n/uk.ts","web/src/i18n/zh-hant.ts","web/src/i18n/zh.ts","web/src/lib/api.test.ts","web/src/lib/api.ts","web/src/lib/clipboard.test.ts","web/src/lib/clipboard.ts"],"fix_hunks":[{"file":"hermes_cli/dashboard_auth/middleware.py","hunks":["@@ -150,6 +150,8 @@ def _auto_sso_response(request: Request) -> Response | None:\n       * exactly ONE interactive provider is registered — with two or more we\n         can't pick for the user, so the ``/login`` chooser must render; with\n         zero there's nothing to redirect to;\n+      * that provider is OAuth-style, not a password form provider. Password\n+        providers must render ``/login`` so the user can enter credentials;\n       * the one-shot loop-guard marker is ABSENT. Its presence means we\n         already bounced to the portal once and came back still\n         unauthenticated (no portal session) — auto-redirecting again would","@@ -185,6 +187,9 @@ def _auto_sso_response(request: Request) -> Response | None:\n     from hermes_cli.dashboard_auth.prefix import prefix_from_request\n \n     provider = providers[0]\n+    if getattr(provider, \"supports_password\", False):\n+        return None\n+\n     prefix = prefix_from_request(request)\n     next_param = _safe_next_target(request)\n     from urllib.parse import quote","@@ -458,4 +463,3 @@ def _attempt_refresh(request: Request, *, refresh_token):\n         if new_session is not None:\n             return new_session, provider.name\n     return None\n-"]},{"file":"hermes_cli/dashboard_auth/routes.py","hunks":["@@ -192,6 +192,14 @@ async def auth_login(request: Request, provider: str, next: str = \"\"):\n             status_code=404,\n             detail=f\"Provider does not support interactive login: {provider!r}\",\n         )\n+    if getattr(p, \"supports_password\", False):\n+        from urllib.parse import quote\n+\n+        safe_next = _validate_post_login_target(next)\n+        login_url = f\"{_prefix(request)}/login\"\n+        if safe_next:\n+            login_url = f\"{login_url}?next={quote(safe_next, safe='')}\"\n+        return RedirectResponse(url=login_url, status_code=302)\n \n     try:\n         ls = p.start_login(redirect_uri=_redirect_uri(request))"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -9087,6 +9087,54 @@ def _xai_device_poller(session_id: str) -> None:\n             sess[\"error_message\"] = str(e)\n \n \n+def _http_response_error_detail(resp: Any) -> str:\n+    \"\"\"Best-effort extraction of a short provider error detail.\"\"\"\n+    try:\n+        payload = resp.json()\n+    except Exception:\n+        payload = None\n+    if isinstance(payload, dict):\n+        error = payload.get(\"error\")\n+        if isinstance(error, dict):\n+            parts = [\n+                str(error.get(key, \"\")).strip()\n+                for key in (\"message\", \"error_description\", \"code\", \"type\")\n+                if str(error.get(key, \"\")).strip()\n+            ]\n+            if parts:\n+                return \": \".join(parts)\n+        if isinstance(error, str) and error.strip():\n+            return error.strip()\n+        for key in (\"detail\", \"message\", \"error_description\"):\n+            value = payload.get(key)\n+            if isinstance(value, str) and value.strip():\n+                return value.strip()\n+    text = str(getattr(resp, \"text\", \"\") or \"\").strip()\n+    return text[:500]\n+\n+\n+def _codex_device_code_start_error(resp: Any) -> str:\n+    \"\"\"Dashboard-facing OpenAI Codex device-code start failure.\"\"\"\n+    status = getattr(resp, \"status_code\", \"unknown\")\n+    detail = _http_response_error_detail(resp)\n+    lower = detail.lower()\n+    if \"device\" in lower and (\"authori\" in lower or \"enable\" in lower):\n+        message = (\n+            \"OpenAI rejected the device-code login request. Your OpenAI \"\n+            \"account may need device-code authorization enabled before Hermes \"\n+            \"can start this dashboard login. Enable device-code authorization \"\n+            \"in OpenAI, then return here and click Login again.\"\n+        )\n+    else:\n+        message = (\n+            \"OpenAI rejected the device-code login request. Please try Login \"\n+            \"again from the dashboard after checking your OpenAI account settings.\"\n+        )\n+    if detail:\n+        return f\"{message} (HTTP {status}: {detail})\"\n+    return f\"{message} (HTTP {status})\"\n+\n+\n def _codex_full_login_worker(session_id: str) -> None:\n     \"\"\"Run the complete OpenAI Codex device-code flow.\n ","@@ -9119,7 +9167,7 @@ def _codex_full_login_worker(session_id: str) -> None:\n                 headers={\"Content-Type\": \"application/json\"},\n             )\n         if resp.status_code != 200:\n-            raise RuntimeError(f\"deviceauth/usercode returned {resp.status_code}\")\n+            raise RuntimeError(_codex_device_code_start_error(resp))\n         device_data = resp.json()\n         user_code = device_data.get(\"user_code\", \"\")\n         device_auth_id = device_data.get(\"device_auth_id\", \"\")"]},{"file":"tests/hermes_cli/test_dashboard_auth_password_login.py","hunks":["@@ -198,6 +198,24 @@ def test_providers_endpoint_reports_supports_password(self, gated_app):\n         prov = {p[\"name\"]: p for p in resp.json()[\"providers\"]}\n         assert prov[\"testpw\"][\"supports_password\"] is True\n \n+    def test_password_provider_html_redirects_to_login_form(self, gated_app):\n+        resp = gated_app.get(\"/\", follow_redirects=False)\n+        assert resp.status_code == 302\n+        assert resp.headers[\"location\"] == \"/login?next=%2F\"\n+\n+        login = gated_app.get(resp.headers[\"location\"])\n+        assert login.status_code == 200\n+        assert '<form class=\"provider-form\" data-provider=\"testpw\"' in login.text\n+        assert \"/auth/password-login\" in login.text\n+\n+    def test_password_provider_auth_login_redirects_to_login_form(self, gated_app):\n+        resp = gated_app.get(\n+            \"/auth/login?provider=testpw&next=%2F\",\n+            follow_redirects=False,\n+        )\n+        assert resp.status_code == 302\n+        assert resp.headers[\"location\"] == \"/login?next=%2F\"\n+\n     def test_oauth_provider_reports_false(self):\n         clear_providers()\n         register_provider(StubAuthProvider())"]},{"file":"tests/hermes_cli/test_dashboard_oauth_endpoints_server_gate.py","hunks":["@@ -0,0 +1,69 @@\n+\"\"\"Regression guard for PR #61281 (mobile/hosted dashboard OAuth).\n+\n+The PR removed the *client-side* ``X-Hermes-Session-Token`` requirement from\n+the dashboard OAuth mutation calls (``web/src/lib/api.ts``) so that\n+cookie-authenticated hosted/mobile sessions can start provider logins. The\n+safety of that change rests entirely on the *server* still gating those\n+endpoints: in gated mode the ``gated_auth_middleware`` verifies the session\n+cookie before the handler runs, and ``_require_token`` defers to it.\n+\n+These tests pin that server-side gate for the exact endpoints whose\n+client-side token gate was removed. Without them, a future change that\n+re-broke ``_require_token``'s gated-mode branch (e.g. letting it fall through\n+without a session) would still pass the PR's ``api.test.ts`` suite, because\n+those tests only mock ``fetch`` and never touch the server.\n+\"\"\"\n+\n+import pytest\n+from fastapi.testclient import TestClient\n+\n+from hermes_cli import web_server\n+from hermes_cli.dashboard_auth import clear_providers, register_provider\n+from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider\n+\n+\n[redacted:email]\n+def gated_app():\n+    \"\"\"A gated (``auth_required``) dashboard with no session cookie set.\"\"\"\n+    clear_providers()\n+    register_provider(StubAuthProvider())\n+    prev_host = getattr(web_server.app.state, \"bound_host\", None)\n+    prev_port = getattr(web_server.app.state, \"bound_port\", None)\n+    prev_required = getattr(web_server.app.state, \"auth_required\", None)\n+    web_server.app.state.bound_host = \"fly-app.fly.dev\"\n+    web_server.app.state.bound_port = 443\n+    web_server.app.state.auth_required = True\n+    client = TestClient(web_server.app, base_url=\"https://fly-app.fly.dev\")\n+    yield client\n+    clear_providers()\n+    web_server.app.state.bound_host = prev_host\n+    web_server.app.state.bound_port = prev_port\n+    web_server.app.state.auth_required = prev_required\n+\n+\n+class TestOAuthMutationEndpointsGatedWithoutCookie:\n+    \"\"\"No cookie in gated mode -> 401 on every endpoint whose client-side\n+    session-token gate PR #61281 removed.\"\"\"\n+\n+    def test_env_reveal_requires_cookie(self, gated_app):\n+        r = gated_app.post(\"/api/env/reveal\", json={\"key\": \"OPENAI_API_KEY\"})\n+        assert r.status_code == 401\n+\n+    def test_oauth_disconnect_requires_cookie(self, gated_app):\n+        r = gated_app.delete(\"/api/providers/oauth/anthropic\")\n+        assert r.status_code == 401\n+\n+    def test_oauth_start_requires_cookie(self, gated_app):\n+        r = gated_app.post(\"/api/providers/oauth/anthropic/start\", json={})\n+        assert r.status_code == 401\n+\n+    def test_oauth_submit_requires_cookie(self, gated_app):\n+        r = gated_app.post(\n+            \"/api/providers/oauth/anthropic/submit\",\n+            json={\"session_id\": \"sid\", \"code\": \"abc\"},\n+        )\n+        assert r.status_code == 401\n+\n+    def test_oauth_cancel_session_requires_cookie(self, gated_app):\n+        r = gated_app.delete(\"/api/providers/oauth/sessions/sid\")\n+        assert r.status_code == 401"]},{"file":"tests/hermes_cli/test_web_oauth_dispatch.py","hunks":["@@ -340,6 +340,56 @@ def post(self, url, **kwargs):\n         ws._oauth_sessions.pop(sid, None)\n \n \n+def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch):\n+    from hermes_cli import web_server as ws\n+\n+    before_sessions = set(ws._oauth_sessions)\n+\n+    class _Resp:\n+        status_code = 400\n+        text = \"Enable device code authorization\"\n+\n+        def json(self):\n+            return {\n+                \"error\": {\n+                    \"message\": \"Enable device code authorization\",\n+                    \"code\": \"device_authorization_not_enabled\",\n+                }\n+            }\n+\n+    class _Client:\n+        def __init__(self, *args, **kwargs):\n+            pass\n+\n+        def __enter__(self):\n+            return self\n+\n+        def __exit__(self, *args):\n+            return False\n+\n+        def post(self, url, **kwargs):\n+            assert url.endswith(\"/deviceauth/usercode\")\n+            return _Resp()\n+\n+    monkeypatch.setattr(httpx, \"Client\", _Client)\n+\n+    try:\n+        resp = client.post(\n+            \"/api/providers/oauth/openai-codex/start\",\n+            headers=HEADERS,\n+        )\n+\n+        assert resp.status_code == 500\n+        detail = resp.json()[\"detail\"]\n+        assert \"OpenAI rejected the device-code login request\" in detail\n+        assert \"Enable device-code authorization in OpenAI\" in detail\n+        assert \"click Login again\" in detail\n+        assert \"hermes auth\" not in detail\n+    finally:\n+        for sid in set(ws._oauth_sessions) - before_sessions:\n+            ws._oauth_sessions.pop(sid, None)\n+\n+\n def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):\n     from hermes_cli import auth as auth_mod\n     from hermes_cli import web_server as ws"]},{"file":"web/src/components/OAuthLoginModal.tsx","hunks":["@@ -1,10 +1,10 @@\n import { useEffect, useRef, useState } from \"react\";\n-import { ExternalLink, X, Check } from \"lucide-react\";\n+import { ExternalLink, X, Check, Copy } from \"lucide-react\";\n import { Button } from \"@nous-research/ui/ui/components/button\";\n-import { CopyButton } from \"@nous-research/ui/ui/components/command-block\";\n import { Spinner } from \"@nous-research/ui/ui/components/spinner\";\n import { H2 } from \"@nous-research/ui/ui/components/typography/h2\";\n import { api, type OAuthProvider, type OAuthStartResponse } from \"@/lib/api\";\n+import { copyTextToClipboard } from \"@/lib/clipboard\";\n import { Input } from \"@nous-research/ui/ui/components/input\";\n import { useI18n } from \"@/i18n\";\n import { cn, themedBody } from \"@/lib/utils\";","@@ -30,9 +30,13 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {\n   const [start, setStart] = useState<OAuthStartResponse | null>(null);\n   const [pkceCode, setPkceCode] = useState(\"\");\n   const [errorMsg, setErrorMsg] = useState<string | null>(null);\n+  const [copyStatus, setCopyStatus] = useState<\"idle\" | \"copied\" | \"failed\">(\n+    \"idle\",\n+  );\n   const [secondsLeft, setSecondsLeft] = useState<number | null>(null);\n   const isMounted = useRef(true);\n   const pollTimer = useRef<number | null>(null);\n+  const copyResetTimer = useRef<number | null>(null);\n   const { t } = useI18n();\n \n   // Initiate flow on mount","@@ -59,6 +63,8 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {\n     return () => {\n       isMounted.current = false;\n       if (pollTimer.current !== null) window.clearInterval(pollTimer.current);\n+      if (copyResetTimer.current !== null)\n+        window.clearTimeout(copyResetTimer.current);\n     };\n     // eslint-disable-next-line react-hooks/exhaustive-deps\n   }, []);","@@ -162,6 +168,24 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {\n     return `${m}:${String(r).padStart(2, \"0\")}`;\n   };\n \n+  const handleCopyDeviceCode = async (code: string) => {\n+    if (copyResetTimer.current !== null) {\n+      window.clearTimeout(copyResetTimer.current);\n+      copyResetTimer.current = null;\n+    }\n+    const copied = await copyTextToClipboard(code);\n+    if (!isMounted.current) return;\n+    setCopyStatus(copied ? \"copied\" : \"failed\");\n+    copyResetTimer.current = window.setTimeout(() => {\n+      if (isMounted.current) setCopyStatus(\"idle\");\n+      copyResetTimer.current = null;\n+    }, 2000);\n+  };\n+\n+  const deviceCode = start?.flow === \"device_code\" ? start.user_code : \"\";\n+  const verificationUrl =\n+    start?.flow === \"device_code\" ? start.verification_url : \"\";\n+\n   return (\n     <div\n       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) {\n               </p>\n               <div className=\"flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4\">\n                 <code className=\"font-mono-ui text-2xl tracking-widest text-foreground\">\n-                  {\n-                    (\n-                      start as Extract<\n-                        OAuthStartResponse,\n-                        { flow: \"device_code\" }\n-                      >\n-                    ).user_code\n-                  }\n+                  {deviceCode}\n                 </code>\n-                <CopyButton\n-                  text={\n-                    (\n-                      start as Extract<\n-                        OAuthStartResponse,\n-                        { flow: \"device_code\" }\n-                      >\n-                    ).user_code\n+                <Button\n+                  size=\"sm\"\n+                  outlined\n+                  className=\"shrink-0 uppercase\"\n+                  onClick={() => void handleCopyDeviceCode(deviceCode)}\n+                  prefix={\n+                    copyStatus === \"copied\" ? (\n+                      <Check className=\"h-4 w-4\" />\n+                    ) : (\n+                      <Copy className=\"h-4 w-4\" />\n+                    )\n                   }\n-                />\n+                  aria-label={t.oauth.copyCode}\n+                >\n+                  {copyStatus === \"copied\" ? t.oauth.copied : t.oauth.copyCode}\n+                </Button>\n               </div>\n+              {copyStatus === \"failed\" && (\n+                <p className=\"text-xs text-destructive\">\n+                  {t.oauth.copyFailed}\n+                </p>\n+              )}\n               <a\n-                href={\n-                  (\n-                    start as Extract<\n-                      OAuthStartResponse,\n-                      { flow: \"device_code\" }\n-                    >\n-                  ).verification_url\n-                }\n+                href={verificationUrl}\n                 target=\"_blank\"\n                 rel=\"noopener noreferrer\"\n                 className=\"text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1\""]},{"file":"web/src/i18n/af.ts","hunks":["@@ -446,16 +446,19 @@ export const af: Translations = {\n   oauth: {\n     title: \"Verskaffer-aanmeldings (OAuth)\",\n     providerLogins: \"Verskaffer-aanmeldings (OAuth)\",\n-    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.\",\n+    description:\n+      \"{connected} van {total} OAuth-verskaffers gekoppel. Gebruik Meld aan vir vloeie wat die kontroleskerm ondersteun; CLI-opdragte bly beskikbaar vir eksterne of terugval-opstelling.\",\n     connected: \"Gekoppel\",\n     expired: \"Verval\",\n-    notConnected: \"Nie gekoppel nie. Voer {command} uit in 'n terminaal.\",\n+    notConnected: \"Nie gekoppel nie. Gebruik Meld aan indien beskikbaar, of voer {command} uit in 'n terminaal.\",\n     runInTerminal: \"in 'n terminaal.\",\n     noProviders: \"Geen OAuth-bekwame verskaffers opgespoor nie.\",\n     login: \"Meld aan\",\n     disconnect: \"Ontkoppel\",\n     managedExternally: \"Ekstern bestuur\",\n     copied: \"Gekopieer ✓\",\n+    copyCode: \"Kopieer kode\",\n+    copyFailed: \"Kon nie outomaties kopieer nie. Kies die kode en kopieer dit met die hand.\",\n     cli: \"Kopieer\",\n     copyCliCommand: \"Kopieer CLI-opdrag (vir ekstern / terugval)\",\n     connect: \"Koppel\","]},{"file":"web/src/i18n/de.ts","hunks":["@@ -446,16 +446,19 @@ export const de: Translations = {\n   oauth: {\n     title: \"Anbieter-Logins (OAuth)\",\n     providerLogins: \"Anbieter-Logins (OAuth)\",\n-    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.\",\n+    description:\n+      \"{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.\",\n     connected: \"Verbunden\",\n     expired: \"Abgelaufen\",\n-    notConnected: \"Nicht verbunden. Führe {command} in einem Terminal aus.\",\n+    notConnected: \"Nicht verbunden. Nutze Anmelden, falls verfügbar, oder führe {command} in einem Terminal aus.\",\n     runInTerminal: \"in einem Terminal.\",\n     noProviders: \"Keine OAuth-fähigen Anbieter erkannt.\",\n     login: \"Anmelden\",\n     disconnect: \"Trennen\",\n     managedExternally: \"Extern verwaltet\",\n     copied: \"Kopiert ✓\",\n+    copyCode: \"Code kopieren\",\n+    copyFailed: \"Automatisches Kopieren fehlgeschlagen. Markiere den Code und kopiere ihn manuell.\",\n     cli: \"Kopieren\",\n     copyCliCommand: \"CLI-Befehl kopieren (für extern / Fallback)\",\n     connect: \"Verbinden\","]},{"file":"web/src/i18n/en.ts","hunks":["@@ -501,16 +501,19 @@ export const en: Translations = {\n   oauth: {\n     title: \"Provider Logins (OAuth)\",\n     providerLogins: \"Provider Logins (OAuth)\",\n-    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.\",\n+    description:\n+      \"{connected} of {total} OAuth providers connected. Use Login for dashboard-supported flows; CLI commands remain available for external or fallback setup.\",\n     connected: \"Connected\",\n     expired: \"Expired\",\n-    notConnected: \"Not connected. Run {command} in a terminal.\",\n+    notConnected: \"Not connected. Use Login when available, or run {command} in a terminal.\",\n     runInTerminal: \"in a terminal.\",\n     noProviders: \"No OAuth-capable providers detected.\",\n     login: \"Login\",\n     disconnect: \"Disconnect\",\n     managedExternally: \"Managed externally\",\n     copied: \"Copied ✓\",\n+    copyCode: \"Copy code\",\n+    copyFailed: \"Could not copy automatically. Select the code and copy it manually.\",\n     cli: \"Copy\",\n     copyCliCommand: \"Copy CLI command (for external / fallback)\",\n     connect: \"Connect\","]},{"file":"web/src/i18n/es.ts","hunks":["@@ -447,16 +447,19 @@ export const es: Translations = {\n   oauth: {\n     title: \"Inicios de sesión de proveedores (OAuth)\",\n     providerLogins: \"Inicios de sesión de proveedores (OAuth)\",\n-    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.\",\n+    description:\n+      \"{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.\",\n     connected: \"Conectado\",\n     expired: \"Caducado\",\n-    notConnected: \"No conectado. Ejecuta {command} en una terminal.\",\n+    notConnected: \"No conectado. Usa Iniciar sesión si está disponible, o ejecuta {command} en una terminal.\",\n     runInTerminal: \"en una terminal.\",\n     noProviders: \"No se han detectado proveedores compatibles con OAuth.\",\n     login: \"Iniciar sesión\",\n     disconnect: \"Desconectar\",\n     managedExternally: \"Gestionado externamente\",\n     copied: \"Copiado ✓\",\n+    copyCode: \"Copiar código\",\n+    copyFailed: \"No se pudo copiar automáticamente. Selecciona el código y cópialo manualmente.\",\n     cli: \"Copiar\",\n     copyCliCommand: \"Copiar comando CLI (para externo / alternativa)\",\n     connect: \"Conectar\","]},{"file":"web/src/i18n/fr.ts","hunks":["@@ -447,16 +447,19 @@ export const fr: Translations = {\n   oauth: {\n     title: \"Connexions fournisseurs (OAuth)\",\n     providerLogins: \"Connexions fournisseurs (OAuth)\",\n-    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.\",\n+    description:\n+      \"{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.\",\n     connected: \"Connecté\",\n     expired: \"Expiré\",\n-    notConnected: \"Non connecté. Exécutez {command} dans un terminal.\",\n+    notConnected: \"Non connecté. Utilisez Connexion si disponible, ou exécutez {command} dans un terminal.\",\n     runInTerminal: \"dans un terminal.\",\n     noProviders: \"Aucun fournisseur compatible OAuth détecté.\",\n     login: \"Connexion\",\n     disconnect: \"Déconnecter\",\n     managedExternally: \"Géré en externe\",\n     copied: \"Copié ✓\",\n+    copyCode: \"Copier le code\",\n+    copyFailed: \"Copie automatique impossible. Sélectionnez le code et copiez-le manuellement.\",\n     cli: \"Copier\",\n     copyCliCommand: \"Copier la commande CLI (pour externe / repli)\",\n     connect: \"Connecter\","]},{"file":"web/src/i18n/ga.ts","hunks":["@@ -454,16 +454,19 @@ export const ga: Translations = {\n   oauth: {\n     title: \"Logálacha isteach soláthraí (OAuth)\",\n     providerLogins: \"Logálacha isteach soláthraí (OAuth)\",\n-    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ú.\",\n+    description:\n+      \"{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.\",\n     connected: \"Ceangailte\",\n     expired: \"As feidhm\",\n-    notConnected: \"Gan cheangal. Rith {command} i dteirminéal.\",\n+    notConnected: \"Gan cheangal. Úsáid Logáil isteach má tá sé ar fáil, nó rith {command} i dteirminéal.\",\n     runInTerminal: \"i dteirminéal.\",\n     noProviders: \"Níor aimsíodh soláthraithe a thacaíonn le OAuth.\",\n     login: \"Logáil isteach\",\n     disconnect: \"Dícheangail\",\n     managedExternally: \"Bainistithe go seachtrach\",\n     copied: \"Cóipeáilte ✓\",\n+    copyCode: \"Cóipeáil an cód\",\n+    copyFailed: \"Níorbh fhéidir cóipeáil go huathoibríoch. Roghnaigh an cód agus cóipeáil de láimh é.\",\n     cli: \"Cóipeáil\",\n     copyCliCommand: \"Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)\",\n     connect: \"Ceangail\","]},{"file":"web/src/i18n/hu.ts","hunks":["@@ -446,16 +446,19 @@ export const hu: Translations = {\n   oauth: {\n     title: \"Szolgáltatói bejelentkezések (OAuth)\",\n     providerLogins: \"Szolgáltatói bejelentkezések (OAuth)\",\n-    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.\",\n+    description:\n+      \"{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.\",\n     connected: \"Csatlakoztatva\",\n     expired: \"Lejárt\",\n-    notConnected: \"Nincs csatlakoztatva. Futtassa a {command} parancsot egy terminálban.\",\n+    notConnected: \"Nincs csatlakoztatva. Használja a Bejelentkezés gombot, ha elérhető, vagy futtassa a {command} parancsot egy terminálban.\",\n     runInTerminal: \"egy terminálban.\",\n     noProviders: \"Nem észlelhető OAuth-képes szolgáltató.\",\n     login: \"Bejelentkezés\",\n     disconnect: \"Lecsatlakozás\",\n     managedExternally: \"Külsőleg kezelt\",\n     copied: \"Másolva ✓\",\n+    copyCode: \"Kód másolása\",\n+    copyFailed: \"Nem sikerült automatikusan másolni. Jelölje ki a kódot, és másolja kézzel.\",\n     cli: \"Másolás\",\n     copyCliCommand: \"CLI-parancs másolása (külső / tartalék)\",\n     connect: \"Csatlakozás\","]},{"file":"web/src/i18n/it.ts","hunks":["@@ -446,16 +446,19 @@ export const it: Translations = {\n   oauth: {\n     title: \"Accessi provider (OAuth)\",\n     providerLogins: \"Accessi provider (OAuth)\",\n-    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.\",\n+    description:\n+      \"{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.\",\n     connected: \"Connesso\",\n     expired: \"Scaduto\",\n-    notConnected: \"Non connesso. Esegui {command} in un terminale.\",\n+    notConnected: \"Non connesso. Usa Accedi se disponibile, oppure esegui {command} in un terminale.\",\n     runInTerminal: \"in un terminale.\",\n     noProviders: \"Nessun provider compatibile con OAuth rilevato.\",\n     login: \"Accedi\",\n     disconnect: \"Disconnetti\",\n     managedExternally: \"Gestito esternamente\",\n     copied: \"Copiato ✓\",\n+    copyCode: \"Copia codice\",\n+    copyFailed: \"Impossibile copiare automaticamente. Seleziona il codice e copialo manualmente.\",\n     cli: \"Copia\",\n     copyCliCommand: \"Copia comando CLI (per uso esterno / fallback)\",\n     connect: \"Connetti\","]},{"file":"web/src/i18n/ja.ts","hunks":["@@ -445,16 +445,19 @@ export const ja: Translations = {\n   oauth: {\n     title: \"プロバイダーログイン (OAuth)\",\n     providerLogins: \"プロバイダーログイン (OAuth)\",\n-    description: \"{connected} / {total} OAuth プロバイダーが接続されています。ログインフローは現在 CLI 経由で実行されます。「コマンドをコピー」をクリックして、ターミナルに貼り付けてセットアップしてください。\",\n+    description:\n+      \"{connected} / {total} OAuth プロバイダーが接続されています。ダッシュボード対応のフローには「ログイン」を使用してください。外部またはフォールバック用のセットアップには引き続き CLI コマンドを利用できます。\",\n     connected: \"接続済み\",\n     expired: \"期限切れ\",\n-    notConnected: \"未接続です。ターミナルで {command} を実行してください。\",\n+    notConnected: \"未接続です。可能な場合は「ログイン」を使用するか、ターミナルで {command} を実行してください。\",\n     runInTerminal: \"ターミナルで実行してください。\",\n     noProviders: \"OAuth 対応プロバイダーは検出されませんでした。\",\n     login: \"ログイン\",\n     disconnect: \"切断\",\n     managedExternally: \"外部で管理\",\n     copied: \"コピーしました ✓\",\n+    copyCode: \"コードをコピー\",\n+    copyFailed: \"自動でコピーできませんでした。コードを選択して手動でコピーしてください。\",\n     cli: \"コピー\",\n     copyCliCommand: \"CLI コマンドをコピー (外部 / フォールバック用)\",\n     connect: \"接続\","]},{"file":"web/src/i18n/ko.ts","hunks":["@@ -445,16 +445,19 @@ export const ko: Translations = {\n   oauth: {\n     title: \"제공자 로그인 (OAuth)\",\n     providerLogins: \"제공자 로그인 (OAuth)\",\n-    description: \"{connected}/{total} OAuth 제공자가 연결되었습니다. 로그인 흐름은 현재 CLI를 통해 실행됩니다. 명령 복사를 클릭하고 터미널에 붙여넣어 설정하세요.\",\n+    description:\n+      \"{connected}/{total} OAuth 제공자가 연결되었습니다. 대시보드에서 지원되는 흐름에는 로그인을 사용하세요. 외부 또는 대체 설정에는 CLI 명령을 계속 사용할 수 있습니다.\",\n     connected: \"연결됨\",\n     expired: \"만료됨\",\n-    notConnected: \"연결되지 않음. 터미널에서 {command}을(를) 실행하세요.\",\n+    notConnected: \"연결되지 않음. 가능하면 로그인을 사용하거나 터미널에서 {command}을(를) 실행하세요.\",\n     runInTerminal: \"터미널에서.\",\n     noProviders: \"OAuth를 지원하는 제공자가 감지되지 않았습니다.\",\n     login: \"로그인\",\n     disconnect: \"연결 해제\",\n     managedExternally: \"외부에서 관리됨\",\n     copied: \"복사됨 ✓\",\n+    copyCode: \"코드 복사\",\n+    copyFailed: \"자동으로 복사할 수 없습니다. 코드를 선택하여 직접 복사하세요.\",\n     cli: \"복사\",\n     copyCliCommand: \"CLI 명령 복사 (외부 / 대체용)\",\n     connect: \"연결\","]},{"file":"web/src/i18n/pt.ts","hunks":["@@ -447,16 +447,19 @@ export const pt: Translations = {\n   oauth: {\n     title: \"Inícios de sessão de fornecedor (OAuth)\",\n     providerLogins: \"Inícios de sessão de fornecedor (OAuth)\",\n-    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.\",\n+    description:\n+      \"{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.\",\n     connected: \"Ligado\",\n     expired: \"Expirado\",\n-    notConnected: \"Não ligado. Execute {command} num terminal.\",\n+    notConnected: \"Não ligado. Use Iniciar sessão quando disponível, ou execute {command} num terminal.\",\n     runInTerminal: \"num terminal.\",\n     noProviders: \"Não foram detetados fornecedores compatíveis com OAuth.\",\n     login: \"Iniciar sessão\",\n     disconnect: \"Desligar\",\n     managedExternally: \"Gerido externamente\",\n     copied: \"Copiado ✓\",\n+    copyCode: \"Copiar código\",\n+    copyFailed: \"Não foi possível copiar automaticamente. Selecione o código e copie-o manualmente.\",\n     cli: \"Copiar\",\n     copyCliCommand: \"Copiar comando CLI (para externo / fallback)\",\n     connect: \"Ligar\","]},{"file":"web/src/i18n/ru.ts","hunks":["@@ -446,16 +446,19 @@ export const ru: Translations = {\n   oauth: {\n     title: \"Входы провайдеров (OAuth)\",\n     providerLogins: \"Входы провайдеров (OAuth)\",\n-    description: \"Подключено {connected} из {total} OAuth-провайдеров. Процесс входа в настоящее время выполняется через CLI; нажмите «Скопировать команду» и вставьте в терминал для настройки.\",\n+    description:\n+      \"Подключено {connected} из {total} OAuth-провайдеров. Используйте «Войти» для процессов, поддерживаемых панелью; команды CLI остаются доступными для внешней или резервной настройки.\",\n     connected: \"Подключено\",\n     expired: \"Срок истёк\",\n-    notConnected: \"Не подключено. Выполните {command} в терминале.\",\n+    notConnected: \"Не подключено. Используйте «Войти», если доступно, или выполните {command} в терминале.\",\n     runInTerminal: \"в терминале.\",\n     noProviders: \"OAuth-совместимые провайдеры не обнаружены.\",\n     login: \"Войти\",\n     disconnect: \"Отключить\",\n     managedExternally: \"Управляется извне\",\n     copied: \"Скопировано ✓\",\n+    copyCode: \"Скопировать код\",\n+    copyFailed: \"Не удалось скопировать автоматически. Выделите код и скопируйте его вручную.\",\n     cli: \"Копировать\",\n     copyCliCommand: \"Скопировать CLI-команду (для внешнего / резервного варианта)\",\n     connect: \"Подключить\","]},{"file":"web/src/i18n/tr.ts","hunks":["@@ -446,16 +446,19 @@ export const tr: Translations = {\n   oauth: {\n     title: \"Sağlayıcı Girişleri (OAuth)\",\n     providerLogins: \"Sağlayıcı Girişleri (OAuth)\",\n-    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.\",\n+    description:\n+      \"{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.\",\n     connected: \"Bağlandı\",\n     expired: \"Süresi doldu\",\n-    notConnected: \"Bağlı değil. Bir terminalde {command} komutunu çalıştırın.\",\n+    notConnected: \"Bağlı değil. Mümkünse Giriş'i kullanın veya bir terminalde {command} komutunu çalıştırın.\",\n     runInTerminal: \"bir terminalde.\",\n     noProviders: \"OAuth uyumlu sağlayıcı algılanmadı.\",\n     login: \"Giriş\",\n     disconnect: \"Bağlantıyı kes\",\n     managedExternally: \"Harici olarak yönetiliyor\",\n     copied: \"Kopyalandı ✓\",\n+    copyCode: \"Kodu kopyala\",\n+    copyFailed: \"Otomatik olarak kopyalanamadı. Kodu seçip elle kopyalayın.\",\n     cli: \"Kopyala\",\n     copyCliCommand: \"CLI komutunu kopyala (harici / yedek için)\",\n     connect: \"Bağlan\","]},{"file":"web/src/i18n/types.ts","hunks":["@@ -530,6 +530,8 @@ export interface Translations {\n     disconnect: string;\n     managedExternally: string;\n     copied: string;\n+    copyCode: string;\n+    copyFailed: string;\n     cli: string;\n     copyCliCommand: string;\n     connect: string;"]},{"file":"web/src/i18n/uk.ts","hunks":["@@ -447,16 +447,19 @@ export const uk: Translations = {\n   oauth: {\n     title: \"Входи постачальників (OAuth)\",\n     providerLogins: \"Входи постачальників (OAuth)\",\n-    description: \"Підключено {connected} з {total} постачальників OAuth. Процеси входу наразі виконуються через CLI; натисніть «Скопіювати команду» та вставте у термінал, щоб налаштувати.\",\n+    description:\n+      \"Підключено {connected} з {total} постачальників OAuth. Використовуйте «Увійти» для процесів, підтримуваних панеллю; команди CLI залишаються доступними для зовнішнього або резервного налаштування.\",\n     connected: \"Підключено\",\n     expired: \"Прострочено\",\n-    notConnected: \"Не підключено. Виконайте {command} у терміналі.\",\n+    notConnected: \"Не підключено. Використовуйте «Увійти», якщо доступно, або виконайте {command} у терміналі.\",\n     runInTerminal: \"у терміналі.\",\n     noProviders: \"Не виявлено постачальників із підтримкою OAuth.\",\n     login: \"Увійти\",\n     disconnect: \"Відключити\",\n     managedExternally: \"Керується ззовні\",\n     copied: \"Скопійовано ✓\",\n+    copyCode: \"Скопіювати код\",\n+    copyFailed: \"Не вдалося скопіювати автоматично. Виділіть код і скопіюйте його вручну.\",\n     cli: \"Копіювати\",\n     copyCliCommand: \"Скопіювати CLI-команду (для зовнішнього / резервного варіанту)\",\n     connect: \"Підключити\","]},{"file":"web/src/i18n/zh-hant.ts","hunks":["@@ -445,16 +445,19 @@ export const zhHant: Translations = {\n   oauth: {\n     title: \"提供者登入（OAuth）\",\n     providerLogins: \"提供者登入（OAuth）\",\n-    description: \"已連線 {connected}/{total} 個 OAuth 提供者。登入流程目前透過 CLI 執行；請點擊「複製指令」並貼到終端機完成設定。\",\n+    description:\n+      \"已連線 {connected}/{total} 個 OAuth 提供者。儀表板支援的流程請使用「登入」；CLI 指令仍可用於外部或備用設定。\",\n     connected: \"已連線\",\n     expired: \"已過期\",\n-    notConnected: \"未連線。請在終端機執行 {command}。\",\n+    notConnected: \"未連線。可用時請使用「登入」，或在終端機執行 {command}。\",\n     runInTerminal: \"於終端機。\",\n     noProviders: \"未偵測到支援 OAuth 的提供者。\",\n     login: \"登入\",\n     disconnect: \"中斷連線\",\n     managedExternally: \"由外部管理\",\n     copied: \"已複製 ✓\",\n+    copyCode: \"複製代碼\",\n+    copyFailed: \"無法自動複製。請選取代碼並手動複製。\",\n     cli: \"複製\",\n     copyCliCommand: \"複製 CLI 指令（外部 / 備援用）\",\n     connect: \"連線\","]},{"file":"web/src/i18n/zh.ts","hunks":["@@ -440,16 +440,19 @@ export const zh: Translations = {\n   oauth: {\n     title: \"提供商登录（OAuth）\",\n     providerLogins: \"提供商登录（OAuth）\",\n-    description: \"已连接 {connected}/{total} 个 OAuth 提供商。登录流程目前通过 CLI 运行；点击「复制命令」并粘贴到终端中进行设置。\",\n+    description:\n+      \"已连接 {connected}/{total} 个 OAuth 提供商。仪表板支持的流程请使用「登录」；CLI 命令仍可用于外部或备用设置。\",\n     connected: \"已连接\",\n     expired: \"已过期\",\n-    notConnected: \"未连接。在终端中运行 {command}。\",\n+    notConnected: \"未连接。可用时请使用「登录」，或在终端中运行 {command}。\",\n     runInTerminal: \"在终端中。\",\n     noProviders: \"未检测到支持 OAuth 的提供商。\",\n     login: \"登录\",\n     disconnect: \"断开连接\",\n     managedExternally: \"外部管理\",\n     copied: \"已复制 ✓\",\n+    copyCode: \"复制代码\",\n+    copyFailed: \"无法自动复制。请选中代码并手动复制。\",\n     cli: \"复制\",\n     copyCliCommand: \"复制 CLI 命令（用于外部/备用方式）\",\n     connect: \"连接\","]},{"file":"web/src/lib/api.test.ts","hunks":["@@ -2,21 +2,28 @@ import { afterEach, describe, expect, it, vi } from \"vitest\";\n \n import { api } from \"./api\";\n \n+const SESSION_HEADER = \"X-Hermes-Session-Token\";\n+\n afterEach(() => {\n   vi.restoreAllMocks();\n   vi.unstubAllGlobals();\n });\n \n+function jsonFetchMock(body: unknown = { ok: true }) {\n+  return vi.fn<typeof fetch>(\n+    async () =>\n+      new Response(JSON.stringify(body), {\n+        headers: { \"Content-Type\": \"application/json\" },\n+        status: 200,\n+      }),\n+  );\n+}\n+\n describe(\"api.getModelOptions\", () => {\n   it(\"requests a live model refresh when asked\", async () => {\n     vi.stubGlobal(\"window\", {});\n \n-    const fetchMock = vi.fn(async () =>\n-      new Response(JSON.stringify({ providers: [] }), {\n-        headers: { \"Content-Type\": \"application/json\" },\n-        status: 200,\n-      }),\n-    );\n+    const fetchMock = jsonFetchMock({ providers: [] });\n     vi.stubGlobal(\"fetch\", fetchMock);\n \n     await api.getModelOptions({ refresh: true });","@@ -30,12 +37,7 @@ describe(\"api.getModelOptions\", () => {\n   it(\"keeps explicit profile scoping when refreshing\", async () => {\n     vi.stubGlobal(\"window\", {});\n \n-    const fetchMock = vi.fn(async () =>\n-      new Response(JSON.stringify({ providers: [] }), {\n-        headers: { \"Content-Type\": \"application/json\" },\n-        status: 200,\n-      }),\n-    );\n+    const fetchMock = jsonFetchMock({ providers: [] });\n     vi.stubGlobal(\"fetch\", fetchMock);\n \n     await api.getModelOptions({ profile: \"default\", refresh: true });","@@ -46,3 +48,59 @@ describe(\"api.getModelOptions\", () => {\n     );\n   });\n });\n+\n+describe(\"api OAuth helpers\", () => {\n+  it(\"starts OAuth login in gated mode without requiring an injected session token\", async () => {\n+    vi.stubGlobal(\"window\", { __HERMES_AUTH_REQUIRED__: true });\n+    const fetchMock = jsonFetchMock({\n+      flow: \"device_code\",\n+      session_id: \"oauth-session\",\n+    });\n+    vi.stubGlobal(\"fetch\", fetchMock);\n+\n+    await api.startOAuthLogin(\"openai-codex\");\n+\n+    expect(fetchMock).toHaveBeenCalledWith(\n+      \"/api/providers/oauth/openai-codex/start\",\n+      expect.objectContaining({\n+        body: \"{}\",\n+        credentials: \"[redacted:secret-assignment]\",\n+        method: \"POST\",\n+      }),\n+    );\n+    const headers = fetchMock.mock.calls[0][1]?.headers as Headers;\n+    expect(headers.get(\"Content-Type\")).toBe(\"application/json\");\n+    expect(headers.has(SESSION_HEADER)).toBe(false);\n+  });\n+\n+  it(\"still sends the injected session token for OAuth login in loopback mode\", async () => {\n+    vi.stubGlobal(\"window\", { __HERMES_SESSION_TOKEN__: \"[redacted:secret-assignment]\" });\n+    const fetchMock = jsonFetchMock({\n+      flow: \"device_code\",\n+      session_id: \"oauth-session\",\n+    });\n+    vi.stubGlobal(\"fetch\", fetchMock);\n+\n+    await api.startOAuthLogin(\"openai-codex\");\n+\n+    const headers = fetchMock.mock.calls[0][1]?.headers as Headers;\n+    expect(headers.get(SESSION_HEADER)).toBe(\"loopback-token\");\n+  });\n+\n+  it(\"runs provider auth mutations in gated mode via cookie auth\", async () => {\n+    vi.stubGlobal(\"window\", { __HERMES_AUTH_REQUIRED__: true });\n+    const fetchMock = jsonFetchMock({ ok: true });\n+    vi.stubGlobal(\"fetch\", fetchMock);\n+\n+    await api.disconnectOAuthProvider(\"anthropic\");\n+    await api.submitOAuthCode(\"anthropic\", \"oauth-session\", \"code-123\");\n+    await api.cancelOAuthSession(\"oauth-session\");\n+    await api.revealEnvVar(\"OPENAI_API_KEY\");\n+\n+    for (const call of fetchMock.mock.calls) {\n+      const init = call[1] as RequestInit;\n+      expect(init.credentials).toBe(\"include\");\n+      expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false);\n+    }\n+  });\n+});"]},{"file":"web/src/lib/api.ts","hunks":["@@ -34,7 +34,6 @@ declare global {\n     __HERMES_AUTH_REQUIRED__?: boolean;\n   }\n }\n-let _sessionToken: string | null = null;\n const SESSION_HEADER = \"X-Hermes-Session-Token\";\n \n function setSessionHeader(headers: Headers, token: [redacted:secret-assignment] void {","@@ -197,16 +196,6 @@ function pluginPath(name: string): string {\n   return name.split(\"/\").map(encodeURIComponent).join(\"/\");\n }\n \n-async function getSessionToken(): Promise<string> {\n-  if (_sessionToken) return _sessionToken;\n-  const injected = window.__HERMES_SESSION_TOKEN__;\n-  if (injected) {\n-    _sessionToken = [redacted:secret-assignment]\n-    return _sessionToken;\n-  }\n-  throw new Error(\"Session token not available — page must be served by the Hermes dashboard server\");\n-}\n-\n /**\n  * Fetch a single-use ticket for a WebSocket upgrade in gated mode.\n  *","@@ -553,17 +542,12 @@ export const api = {\n       headers: { \"Content-Type\": \"application/json\" },\n       body: JSON.stringify({ key }),\n     }),\n-  revealEnvVar: async (key: string) => {\n-    const token = await getSessionToken();\n-    return fetchJSON<{ key: string; value: string }>(\"/api/env/reveal\", {\n+  revealEnvVar: (key: string) =>\n+    fetchJSON<{ key: string; value: string }>(\"/api/env/reveal\", {\n       method: \"POST\",\n-      headers: {\n-        \"Content-Type\": \"application/json\",\n-        [SESSION_HEADER]: token,\n-      },\n+      headers: { \"Content-Type\": \"application/json\" },\n       body: JSON.stringify({ key }),\n-    });\n-  },\n+    }),\n \n   // Cron jobs\n   getCronJobs: (profile = \"all\") =>","@@ -788,58 +772,42 @@ export const api = {\n   // OAuth provider management\n   getOAuthProviders: () =>\n     fetchJSON<OAuthProvidersResponse>(\"/api/providers/oauth\"),\n-  disconnectOAuthProvider: async (providerId: string) => {\n-    const token = await getSessionToken();\n-    return fetchJSON<{ ok: boolean; provider: string }>(\n+  disconnectOAuthProvider: (providerId: string) =>\n+    fetchJSON<{ ok: boolean; provider: string }>(\n       `/api/providers/oauth/${encodeURIComponent(providerId)}`,\n       {\n         method: \"DELETE\",\n-        headers: { [SESSION_HEADER]: token },\n       },\n-    );\n-  },\n-  startOAuthLogin: async (providerId: string) => {\n-    const token = await getSessionToken();\n-    return fetchJSON<OAuthStartResponse>(\n+    ),\n+  startOAuthLogin: (providerId: string) =>\n+    fetchJSON<OAuthStartResponse>(\n       `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,\n       {\n         method: \"POST\",\n-        headers: {\n-          \"Content-Type\": \"application/json\",\n-          [SESSION_HEADER]: token,\n-        },\n+        headers: { \"Content-Type\": \"application/json\" },\n         body: \"{}\",\n       },\n-    );\n-  },\n-  submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {\n-    const token = await getSessionToken();\n-    return fetchJSON<OAuthSubmitResponse>(\n+    ),\n+  submitOAuthCode: (providerId: string, sessionId: string, code: string) =>\n+    fetchJSON<OAuthSubmitResponse>(\n       `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,\n       {\n         method: \"POST\",\n-        headers: {\n-          \"Content-Type\": \"application/json\",\n-          [SESSION_HEADER]: token,\n-        },\n+        headers: { \"Content-Type\": \"application/json\" },\n         body: JSON.stringify({ session_id: sessionId, code }),\n       },\n-    );\n-  },\n+    ),\n   pollOAuthSession: (providerId: string, sessionId: string) =>\n     fetchJSON<OAuthPollResponse>(\n       `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,\n     ),\n-  cancelOAuthSession: async (sessionId: string) => {\n-    const token = await getSessionToken();\n-    return fetchJSON<{ ok: boolean }>(\n+  cancelOAuthSession: (sessionId: string) =>\n+    fetchJSON<{ ok: boolean }>(\n       `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,\n       {\n         method: \"DELETE\",\n-        headers: { [SESSION_HEADER]: token },\n       },\n-    );\n-  },\n+    ),\n \n   // Messaging platforms (gateway channels)\n   getMessagingPlatforms: () =>"]},{"file":"web/src/lib/clipboard.test.ts","hunks":["@@ -0,0 +1,114 @@\n+import { afterEach, describe, expect, it, vi } from \"vitest\";\n+import { copyTextToClipboard } from \"./clipboard\";\n+\n+const originalNavigator = globalThis.navigator;\n+const originalDocument = globalThis.document;\n+const originalWindow = globalThis.window;\n+\n+function setGlobal<K extends keyof typeof globalThis>(\n+  key: K,\n+  value: (typeof globalThis)[K] | undefined,\n+) {\n+  Object.defineProperty(globalThis, key, {\n+    configurable: true,\n+    value,\n+  });\n+}\n+\n+afterEach(() => {\n+  setGlobal(\"navigator\", originalNavigator);\n+  setGlobal(\"document\", originalDocument);\n+  setGlobal(\"window\", originalWindow);\n+  vi.restoreAllMocks();\n+});\n+\n+describe(\"copyTextToClipboard\", () => {\n+  it(\"uses navigator.clipboard when available\", async () => {\n+    const writeText = vi.fn().mockResolvedValue(undefined);\n+    setGlobal(\n+      \"navigator\",\n+      { clipboard: { writeText } } as unknown as Navigator,\n+    );\n+    setGlobal(\"document\", undefined);\n+\n+    await expect(copyTextToClipboard(\"CODEX-1234\")).resolves.toBe(true);\n+    expect(writeText).toHaveBeenCalledWith(\"CODEX-1234\");\n+  });\n+\n+  it(\"falls back to selection copy when Clipboard API fails\", async () => {\n+    const writeText = vi.fn().mockRejectedValue(new Error(\"not allowed\"));\n+    const appendChild = vi.fn();\n+    const removeChild = vi.fn();\n+    const execCommand = vi.fn().mockReturnValue(true);\n+    const textarea = {\n+      focus: vi.fn(),\n+      select: vi.fn(),\n+      setAttribute: vi.fn(),\n+      setSelectionRange: vi.fn(),\n+      style: {},\n+      value: \"\",\n+    } as unknown as HTMLTextAreaElement;\n+\n+    setGlobal(\n+      \"navigator\",\n+      { clipboard: { writeText } } as unknown as Navigator,\n+    );\n+    setGlobal(\"document\", {\n+      body: { appendChild, removeChild },\n+      createElement: vi.fn(() => textarea),\n+      execCommand,\n+      getSelection: vi.fn(() => null),\n+    } as unknown as Document);\n+\n+    await expect(copyTextToClipboard(\"CODEX-1234\")).resolves.toBe(true);\n+\n+    expect(writeText).toHaveBeenCalledWith(\"CODEX-1234\");\n+    expect(textarea.value).toBe(\"CODEX-1234\");\n+    expect(appendChild).toHaveBeenCalledWith(textarea);\n+    expect(textarea.select).toHaveBeenCalled();\n+    expect(execCommand).toHaveBeenCalledWith(\"copy\");\n+    expect(removeChild).toHaveBeenCalledWith(textarea);\n+  });\n+\n+  it(\"uses selection copy directly in insecure browser contexts\", async () => {\n+    const writeText = vi.fn().mockResolvedValue(undefined);\n+    const appendChild = vi.fn();\n+    const removeChild = vi.fn();\n+    const execCommand = vi.fn().mockReturnValue(true);\n+    const textarea = {\n+      focus: vi.fn(),\n+      select: vi.fn(),\n+      setAttribute: vi.fn(),\n+      setSelectionRange: vi.fn(),\n+      style: {},\n+      value: \"\",\n+    } as unknown as HTMLTextAreaElement;\n+\n+    setGlobal(\n+      \"navigator\",\n+      { clipboard: { writeText } } as unknown as Navigator,\n+    );\n+    setGlobal(\n+      \"window\",\n+      { isSecureContext: false } as unknown as Window & typeof globalThis,\n+    );\n+    setGlobal(\"document\", {\n+      body: { appendChild, removeChild },\n+      createElement: vi.fn(() => textarea),\n+      execCommand,\n+      getSelection: vi.fn(() => null),\n+    } as unknown as Document);\n+\n+    await expect(copyTextToClipboard(\"CODEX-1234\")).resolves.toBe(true);\n+\n+    expect(writeText).not.toHaveBeenCalled();\n+    expect(execCommand).toHaveBeenCalledWith(\"copy\");\n+  });\n+\n+  it(\"returns false when no copy mechanism is available\", async () => {\n+    setGlobal(\"navigator\", {} as Navigator);\n+    setGlobal(\"document\", undefined);\n+\n+    await expect(copyTextToClipboard(\"CODEX-1234\")).resolves.toBe(false);\n+  });\n+});"]},{"file":"web/src/lib/clipboard.ts","hunks":["@@ -0,0 +1,56 @@\n+export async function copyTextToClipboard(text: string): Promise<boolean> {\n+  const clipboard =\n+    typeof navigator === \"undefined\" ? undefined : navigator.clipboard;\n+  const secureContext =\n+    typeof window === \"undefined\" ? true : window.isSecureContext;\n+  if (secureContext && clipboard?.writeText) {\n+    try {\n+      await clipboard.writeText(text);\n+      return true;\n+    } catch {\n+      // Fall through to the selection-based copy path below.\n+    }\n+  }\n+\n+  if (typeof document === \"undefined\") {\n+    return false;\n+  }\n+\n+  const textarea = document.createElement(\"textarea\");\n+  textarea.value = text;\n+  textarea.setAttribute(\"readonly\", \"\");\n+  textarea.style.position = \"fixed\";\n+  textarea.style.top = \"-1000px\";\n+  textarea.style.left = \"-1000px\";\n+  textarea.style.opacity = \"0\";\n+  document.body.appendChild(textarea);\n+\n+  const selection = document.getSelection();\n+  const ranges: Range[] = [];\n+  if (selection) {\n+    for (let i = 0; i < selection.rangeCount; i += 1) {\n+      ranges.push(selection.getRangeAt(i));\n+    }\n+  }\n+\n+  textarea.focus();\n+  textarea.select();\n+  textarea.setSelectionRange(0, textarea.value.length);\n+\n+  let copied = false;\n+  try {\n+    copied = document.execCommand(\"copy\");\n+  } catch {\n+    copied = false;\n+  } finally {\n+    document.body.removeChild(textarea);\n+    if (selection) {\n+      selection.removeAllRanges();\n+      for (const range of ranges) {\n+        selection.addRange(range);\n+      }\n+    }\n+  }\n+\n+  return copied;\n+}\n"]}]},{"kind":"pr","number":61342,"title":"fix(dashboard-auth): redirect password providers to /login instead of 500","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":62786,"title":"fix(dashboard): render password form for supports_password providers in OAuthLoginModal","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ed9e8ba097ec8075b11bb3f36538b798b90fa0a0","number":0},{"kind":"pr","value":"#62782","number":62782}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}