{"repo":"NousResearch/hermes-agent","public_id":"34755","display_label":"Dashboard gated/basic-auth session token and OAuth fixes","summary":"A cluster of bugs and fixes around the dashboard failing to authenticate requests in gated or basic-auth mode, covering missing session token injection, _require_token endpoints returning 401 for cookie-authenticated users, and OAuth provider login breakage across chat, plugins, and key-reveal flows.","subsystem":"dashboard","counts":{"members":18,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":7,"merged_prs":2,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34793},"resolution":{"state":"partially_resolved","by":"pr-34793"},"freshness":{"state":"verified","last_verified_at":"2026-06-10T05:57:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.11607843137254899},"fix_edges":[{"issue_number":34755,"pr_number":34793,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":35492,"pr_number":42578,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":42139,"pr_number":42578,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":34755,"title":"[Bug]: Chat tab unusable in gated (OAuth) mode — \"Session token unavailable\" false positive","url":null,"state":"closed"},{"kind":"issue","number":35492,"title":"[Bug]: Gated mode - Dashbaord not loading available plugins","url":null,"state":"closed"},{"kind":"issue","number":40485,"title":"fix(dashboard): 'Session token unavailable' error on chat page when using basic auth","url":null,"state":"closed"},{"kind":"issue","number":42139,"title":"Gated dashboard mode: endpoints calling _require_token() 401 cookie-authenticated clients (breaks custom-endpoint validation)","url":null,"state":"closed"},{"kind":"issue","number":55210,"title":"Bug: Dashboard Keys page reveal button throws in gated (basic_auth) mode — frontend never sends the request","url":null,"state":"closed"},{"kind":"issue","number":58207,"title":"[Bug]: Dashboard OAuth provider login fails in gated mode because startOAuthLogin requires legacy session token","url":null,"state":"closed"},{"kind":"issue","number":59274,"title":"Dashboard model-provider OAuth fails under basic_auth: `__HERMES_SESSION_TOKEN__` never injected (\"Session token not available\")","url":null,"state":"closed"},{"kind":"pr","number":34793,"title":"fix(dashboard): chat tab works in gated (OAuth) mode","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["web/src/pages/ChatPage.tsx"],"fix_hunks":[{"file":"web/src/pages/ChatPage.tsx","hunks":["@@ -119,8 +119,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {\n   const [searchParams, setSearchParams] = useSearchParams();\n   // Lazy-init: the missing-token check happens at construction so the effect\n   // body doesn't have to setState (React 19's set-state-in-effect rule).\n+  // In gated (OAuth) mode the server intentionally omits the session token —\n+  // the SPA authenticates the WS via a single-use ticket (buildWsAuthParam),\n+  // so a missing token there is expected, not an error.\n   const [banner, setBanner] = useState<string | null>(() =>\n-    typeof window !== \"undefined\" && !window.__HERMES_SESSION_TOKEN__\n+    typeof window !== \"undefined\" &&\n+    !window.__HERMES_SESSION_TOKEN__ &&\n+    !window.__HERMES_AUTH_REQUIRED__\n       ? \"Session token unavailable. Open this page through `hermes dashboard`, not directly.\"\n       : null,\n   );","@@ -273,8 +278,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {\n     if (!host) return;\n \n     const token = [redacted:secret-assignment]\n+    const gated = !!window.__HERMES_AUTH_REQUIRED__;\n     // Banner already initialised above; just bail before wiring xterm/WS.\n-    if (!token) {\n+    // In gated mode the token is absent by design — buildWsAuthParam() mints\n+    // a WS ticket instead, so don't bail; let the effect reach that path.\n+    if (!token && !gated) {\n       return;\n     }\n ","@@ -876,5 +884,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {\n declare global {\n   interface Window {\n     __HERMES_SESSION_TOKEN__?: string;\n+    __HERMES_AUTH_REQUIRED__?: boolean;\n   }\n }\n"]}]},{"kind":"pr","number":35500,"title":"fix(dashboard): skip legacy token check in gated mode for _require_token endpoints","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"63a421d4c0e053f9b3241433941bcbca035ad222","number":0},{"kind":"pr","value":"#42578","number":42578}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35589,"title":"fix(dashboard): honor OAuth sessions for plugin endpoints","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"63a421d4c0e053f9b3241433941bcbca035ad222","number":0},{"kind":"pr","value":"#35500","number":35500},{"kind":"pr","value":"#42578","number":42578}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":39319,"title":"fix(dashboard): honor gated OAuth sessions in token guard","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#42578","number":42578},{"kind":"pr","value":"#42578","number":42578}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40547,"title":"fix(dashboard): skip session token check when using basic auth","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1596bb287ea41a72b9807422115548a5247a0b58","number":0},{"kind":"pr","value":"#40485","number":40485}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42148,"title":"fix(dashboard): bypass _require_token in gated mode to trust cookie auth","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"63a421d4c0e053f9b3241433941bcbca035ad222","number":0},{"kind":"pr","value":"#42578","number":42578},{"kind":"pr","value":"#42139","number":42139},{"kind":"pr","value":"#42578","number":42578},{"kind":"pr","value":"#55210","number":55210}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":42578,"title":"fix(dashboard): _require_token endpoints all 401 behind the OAuth gate","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_server.py","tests/hermes_cli/test_dashboard_auth_middleware.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -246,7 +246,31 @@ def _has_valid_session_token(request: Request) -> bool:\n \n \n def _require_token(request: Request) -> None:\n-    \"\"\"Validate the ephemeral session token.  Raises 401 on mismatch.\"\"\"\n+    \"\"\"Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.\n+\n+    Two auth schemes protect the dashboard, exactly one active per bind:\n+\n+    * **Loopback / ``--insecure`` mode** (``auth_required`` False): the\n+      ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed\n+      back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header).\n+      Validate it here.\n+    * **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is\n+      NOT injected (the SPA authenticates with a session cookie), so there is\n+      no token to check. The ``gated_auth_middleware`` has already verified the\n+      cookie before the request reached this handler — any non-public ``/api/``\n+      route it lets through carries a verified ``request.state.session``. The\n+      legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring\n+      the (absent) token here would 401 every cookie-authenticated request,\n+      making plugin install/enable/disable and the other ``_require_token``\n+      endpoints permanently unreachable behind the gate. Defer to the gate.\n+    \"\"\"\n+    if getattr(request.app.state, \"auth_required\", False):\n+        # Gate is authoritative. It attaches ``request.state.session`` on\n+        # success and 401s otherwise, so a request that reached us is already\n+        # authenticated. Belt-and-braces: confirm the session is present.\n+        if getattr(request.state, \"session\", None) is not None:\n+            return\n+        raise HTTPException(status_code=401, detail=\"Unauthorized\")\n     if not _has_valid_session_token(request):\n         raise HTTPException(status_code=401, detail=\"Unauthorized\")\n "]},{"file":"tests/hermes_cli/test_dashboard_auth_middleware.py","hunks":["@@ -191,6 +191,111 @@ def test_full_login_round_trip_unlocks_gated_api(gated_app):\n     )\n \n \n+def _complete_stub_login(client) -> None:\n+    \"\"\"Walk the stub OAuth round trip so ``client`` carries a valid session.\n+\n+    TestClient persists Set-Cookie across calls, so after this returns the\n+    client's cookie jar holds ``hermes_session_at`` / ``hermes_session_rt``\n+    and subsequent gated requests authenticate.\n+    \"\"\"\n+    r1 = client.get(\"/auth/login?provider=stub\", follow_redirects=False)\n+    assert r1.status_code == 302\n+    state = r1.headers[\"location\"].split(\"state=\")[1]\n+    r2 = client.get(\n+        f\"/auth/callback?code=stub_code&state={state}\",\n+        follow_redirects=False,\n+    )\n+    assert r2.status_code == 302\n+\n+\n+def test_gated_require_token_endpoint_accepts_cookie_session(gated_app):\n+    \"\"\"Regression: ``_require_token`` endpoints must work under the OAuth gate.\n+\n+    In gated mode the legacy ``_SESSION_TOKEN`` is NOT injected into the SPA\n+    (it authenticates with the session cookie). Endpoints that call\n+    ``_require_token`` directly — plugin install/enable/disable,\n+    ``/api/dashboard/plugins/hub``, and others — used to re-check the absent\n+    token and 401 every cookie-authenticated request, making them permanently\n+    unreachable behind the gate (the dashboard surfaced a\n+    ``401: {\"detail\":\"Unauthorized\"}`` popup on plugin install). The fix makes\n+    ``_require_token`` defer to the gate, which has already verified the cookie\n+    and attached ``request.state.session`` before the handler runs.\n+\n+    We POST a deliberately invalid plugin identifier: a passing auth layer\n+    lets the request reach the handler, which rejects the identifier with a\n+    400. The assertion is simply \"not 401\" — proving auth succeeded without\n+    coupling to the validation message.\n+    \"\"\"\n+    _complete_stub_login(gated_app)\n+    r = gated_app.post(\n+        \"/api/dashboard/agent-plugins/install\",\n+        json={\"identifier\": \"definitely not a valid identifier\",\n+              \"force\": False, \"enable\": False},\n+    )\n+    assert r.status_code != 401, (\n+        \"A _require_token endpoint 401'd a cookie-authenticated request under \"\n+        f\"the OAuth gate (the install-popup bug). Body: {r.text}\"\n+    )\n+    # And specifically: it reached the handler's own validation.\n+    assert r.status_code == 400, (\n+        f\"Expected the install handler's 400 (bad identifier), got \"\n+        f\"{r.status_code}: {r.text}\"\n+    )\n+\n+\n+def test_gated_require_token_endpoint_still_rejects_no_cookie(gated_app):\n+    \"\"\"The gate must still 401 a ``_require_token`` endpoint with no session.\n+\n+    The fix defers to the gate — it does not make these endpoints public. A\n+    request with no cookie is rejected by ``gated_auth_middleware`` before the\n+    handler runs, so the install endpoint stays protected.\n+    \"\"\"\n+    r = gated_app.post(\n+        \"/api/dashboard/agent-plugins/install\",\n+        json={\"identifier\": \"owner/repo\", \"force\": False, \"enable\": False},\n+    )\n+    assert r.status_code == 401, (\n+        f\"Expected 401 for an unauthenticated install POST under the gate, \"\n+        f\"got {r.status_code}: {r.text}\"\n+    )\n+\n+\n+# A representative spread of the OTHER ``_require_token`` endpoints (there are\n+# 14 in total). The install popup was just the reported symptom; the same bug\n+# made API-key reveal, provider validation, the OAuth-provider connect flow,\n+# and the rest of plugin management unreachable behind the gate. Each entry is\n+# (method, path, json_body); we assert only that a logged-in request is NOT\n+# 401'd — i.e. it cleared the auth layer and reached the handler. The\n+# handler's own status (400/404/429/etc.) is route-specific and not asserted.\n+_GATED_REQUIRE_TOKEN_ROUTES = [\n+    (\"get\", \"/api/dashboard/plugins/hub\", None),\n+    (\"post\", \"/api/env/reveal\", {\"key\": \"NONEXISTENT_ENV_VAR_FOR_TEST\"}),\n+    (\"post\", \"/api/providers/validate\", {\"key\": \"OPENAI_API_KEY\", \"value\": \"\"}),\n+    (\"delete\", \"/api/providers/oauth/__not_a_real_provider__\", None),\n+    (\"post\", \"/api/dashboard/agent-plugins/__nope__/enable\", None),\n+]\n+\n+\n[redacted:email](\"method,path,body\", _GATED_REQUIRE_TOKEN_ROUTES)\n+def test_gated_require_token_routes_accept_cookie_session(\n+    gated_app, method, path, body\n+):\n+    \"\"\"Every ``_require_token`` route must clear auth for a logged-in caller.\n+\n+    Same root cause and fix as\n+    ``test_gated_require_token_endpoint_accepts_cookie_session`` — this just\n+    proves the fix covers the whole class, not only ``agent-plugins/install``.\n+    \"\"\"\n+    _complete_stub_login(gated_app)\n+    kwargs = {\"json\": body} if body is not None else {}\n+    r = gated_app.request(method.upper(), path, **kwargs)\n+    assert r.status_code != 401, (\n+        f\"{method.upper()} {path} 401'd a cookie-authenticated request under \"\n+        f\"the OAuth gate — _require_token still rejecting a valid session. \"\n+        f\"Body: {r.text}\"\n+    )\n+\n+\n def test_login_unknown_provider_returns_404(gated_app):\n     r = gated_app.get(\"/auth/login?provider=nonexistent\", follow_redirects=False)\n     assert r.status_code == 404\n"]}]},{"kind":"pr","number":55220,"title":"fix(dashboard): use fetchJSON auth in revealEnvVar for gated mode","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51156","number":51156},{"kind":"pr","value":"#51156","number":51156},{"kind":"pr","value":"#51156","number":51156}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":55221,"title":"fix(dashboard): inject placeholder session token in gated mode so reveal works","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0},{"kind":"pr","value":"#51156","number":51156},{"kind":"pr","value":"#55220","number":55220}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":58211,"title":"fix(dashboard): allow provider oauth with cookie auth","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":59663,"title":"fix(dashboard): inject session token in gated index.html so model-provider OAuth works under basic_auth (#59274)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e24b16f566045399012bc1185fe0cdb6e1a1be9","number":0},{"kind":"pr","value":"#59274","number":59274}],"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}}