# NousResearch/hermes-agent · Dashboard gated/basic-auth session token and OAuth fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 18 member(s): 0 open issue(s), 7 closed issue(s), 0 open PR(s), 2 merged PR(s), 9 PR(s) closed without merging.
- A fix was merged to main in PR #34793. Verify it addresses your case.
- **Recommended:** read PR #34793 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-34793`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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.12`: the share of member 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.

## Closed / duplicate issues

- #34755: \[Bug\]: Chat tab unusable in gated (OAuth) mode — "Session token unavailable" false positive
- #35492: \[Bug\]: Gated mode - Dashbaord not loading available plugins
- #40485: fix(dashboard): 'Session token unavailable' error on chat page when using basic auth
- #42139: Gated dashboard mode: endpoints calling _require_token() 401 cookie-authenticated clients (breaks custom-endpoint validation)
- #55210: Bug: Dashboard Keys page reveal button throws in gated (basic_auth) mode — frontend never sends the request
- #58207: \[Bug\]: Dashboard OAuth provider login fails in gated mode because startOAuthLogin requires legacy session token
- #59274: Dashboard model-provider OAuth fails under basic_auth: \`__HERMES_SESSION_TOKEN__\` never injected ("Session token not available")

## Prior & competing PRs

- **#34793: fix(dashboard): chat tab works in gated (OAuth) mode**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `web/src/pages/ChatPage.tsx`
  - fix hunks:
  - `web/src/pages/ChatPage.tsx`

    ```diff
    @@ -119,8 +119,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
       const [searchParams, setSearchParams] = useSearchParams();
       // Lazy-init: the missing-token check happens at construction so the effect
       // body doesn't have to setState (React 19's set-state-in-effect rule).
    +  // In gated (OAuth) mode the server intentionally omits the session token —
    +  // the SPA authenticates the WS via a single-use ticket (buildWsAuthParam),
    +  // so a missing token there is expected, not an error.
       const [banner, setBanner] = useState<string | null>(() =>
    -    typeof window !== "undefined" && !window.__HERMES_SESSION_TOKEN__
    +    typeof window !== "undefined" &&
    +    !window.__HERMES_SESSION_TOKEN__ &&
    +    !window.__HERMES_AUTH_REQUIRED__
           ? "Session token unavailable. Open this page through `hermes dashboard`, not directly."
           : null,
       );
    ```

    ```diff
    @@ -273,8 +278,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
         if (!host) return;
     
         const token = [redacted:secret-assignment]
    +    const gated = !!window.__HERMES_AUTH_REQUIRED__;
         // Banner already initialised above; just bail before wiring xterm/WS.
    -    if (!token) {
    +    // In gated mode the token is absent by design — buildWsAuthParam() mints
    +    // a WS ticket instead, so don't bail; let the effect reach that path.
    +    if (!token && !gated) {
           return;
         }
     
    ```

    ```diff
    @@ -876,5 +884,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
     declare global {
       interface Window {
         __HERMES_SESSION_TOKEN__?: string;
    +    __HERMES_AUTH_REQUIRED__?: boolean;
       }
     }
    ```
- **#40547: fix(dashboard): skip session token check when using basic auth**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `1596bb287ea41a72b9807422115548a5247a0b58`, `#40485`
- **#42578: fix(dashboard): _require_token endpoints all 401 behind the OAuth gate**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/web_server.py`, `tests/hermes_cli/test_dashboard_auth_middleware.py`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -246,7 +246,31 @@ def _has_valid_session_token(request: Request) -> bool:
     
     
     def _require_token(request: Request) -> None:
    -    """Validate the ephemeral session token.  Raises 401 on mismatch."""
    +    """Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
    +
    +    Two auth schemes protect the dashboard, exactly one active per bind:
    +
    +    * **Loopback / ``--insecure`` mode** (``auth_required`` False): the
    +      ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed
    +      back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header).
    +      Validate it here.
    +    * **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is
    +      NOT injected (the SPA authenticates with a session cookie), so there is
    +      no token to check. The ``gated_auth_middleware`` has already verified the
    +      cookie before the request reached this handler — any non-public ``/api/``
    +      route it lets through carries a verified ``request.state.session``. The
    +      legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring
    +      the (absent) token here would 401 every cookie-authenticated request,
    +      making plugin install/enable/disable and the other ``_require_token``
    +      endpoints permanently unreachable behind the gate. Defer to the gate.
    +    """
    +    if getattr(request.app.state, "auth_required", False):
    +        # Gate is authoritative. It attaches ``request.state.session`` on
    +        # success and 401s otherwise, so a request that reached us is already
    +        # authenticated. Belt-and-braces: confirm the session is present.
    +        if getattr(request.state, "session", None) is not None:
    +            return
    +        raise HTTPException(status_code=401, detail="Unauthorized")
         if not _has_valid_session_token(request):
             raise HTTPException(status_code=401, detail="Unauthorized")
     
    ```
  - `tests/hermes_cli/test_dashboard_auth_middleware.py`

    ```diff
    @@ -191,6 +191,111 @@ def test_full_login_round_trip_unlocks_gated_api(gated_app):
         )
     
     
    +def _complete_stub_login(client) -> None:
    +    """Walk the stub OAuth round trip so ``client`` carries a valid session.
    +
    +    TestClient persists Set-Cookie across calls, so after this returns the
    +    client's cookie jar holds ``hermes_session_at`` / ``hermes_session_rt``
    +    and subsequent gated requests authenticate.
    +    """
    +    r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
    +    assert r1.status_code == 302
    +    state = r1.headers["location"].split("state=")[1]
    +    r2 = client.get(
    +        f"/auth/callback?code=stub_code&state={state}",
    +        follow_redirects=False,
    +    )
    +    assert r2.status_code == 302
    +
    +
    +def test_gated_require_token_endpoint_accepts_cookie_session(gated_app):
    +    """Regression: ``_require_token`` endpoints must work under the OAuth gate.
    +
    +    In gated mode the legacy ``_SESSION_TOKEN`` is NOT injected into the SPA
    +    (it authenticates with the session cookie). Endpoints that call
    +    ``_require_token`` directly — plugin install/enable/disable,
    +    ``/api/dashboard/plugins/hub``, and others — used to re-check the absent
    +    token and 401 every cookie-authenticated request, making them permanently
    +    unreachable behind the gate (the dashboard surfaced a
    +    ``401: {"detail":"Unauthorized"}`` popup on plugin install). The fix makes
    +    ``_require_token`` defer to the gate, which has already verified the cookie
    +    and attached ``request.state.session`` before the handler runs.
    +
    +    We POST a deliberately invalid plugin identifier: a passing auth layer
    +    lets the request reach the handler, which rejects the identifier with a
    +    400. The assertion is simply "not 401" — proving auth succeeded without
    +    coupling to the validation message.
    +    """
    +    _complete_stub_login(gated_app)
    +    r = gated_app.post(
    +        "/api/dashboard/agent-plugins/install",
    +        json={"identifier": "definitely not a valid identifier",
    +              "force": False, "enable": False},
    +    )
    +    assert r.status_code != 401, (
    +        "A _require_token endpoint 401'd a cookie-authenticated request under "
    +        f"the OAuth gate (the install-popup bug). Body: {r.text}"
    +    )
    +    # And specifically: it reached the handler's own validation.
    +    assert r.status_code == 400, (
    +        f"Expected the install handler's 400 (bad identifier), got "
    +        f"{r.status_code}: {r.text}"
    +    )
    +
    +
    +def test_gated_require_token_endpoint_still_rejects_no_cookie(gated_app):
    +    """The gate must still 401 a ``_require_token`` endpoint with no session.
    +
    +    The fix defers to the gate — it does not make these endpoints public. A
    +    request with no cookie is rejected by ``gated_auth_middleware`` before the
    +    handler runs, so the install endpoint stays protected.
    +    """
    +    r = gated_app.post(
    +        "/api/dashboard/agent-plugins/install",
    +        json={"identifier": "owner/repo", "force": False, "enable": False},
    +    )
    +    assert r.status_code == 401, (
    +        f"Expected 401 for an unauthenticated install POST under the gate, "
    +        f"got {r.status_code}: {r.text}"
    +    )
    +
    +
    +# A representative spread of the OTHER ``_require_token`` endpoints (there are
    +# 14 in total). The install popup was just the reported symptom; the same bug
    +# made API-key reveal, provider validation, the OAuth-provider connect flow,
    +# and the rest of plugin management unreachable behind the gate. Each entry is
    +# (method, path, json_body); we assert only that a logged-in request is NOT
    +# 401'd — i.e. it cleared the auth layer and reached the handler. The
    +# handler's own status (400/404/429/etc.) is route-specific and not asserted.
    +_GATED_REQUIRE_TOKEN_ROUTES = [
    +    ("get", "/api/dashboard/plugins/hub", None),
    +    ("post", "/api/env/reveal", {"key": "NONEXISTENT_ENV_VAR_FOR_TEST"}),
    +    ("post", "/api/providers/validate", {"key": "OPENAI_API_KEY", "value": ""}),
    +    ("delete", "/api/providers/oauth/__not_a_real_provider__", None),
    +    ("post", "/api/dashboard/agent-plugins/__nope__/enable", None),
    +]
    +
    +
    [redacted:email]("method,path,body", _GATED_REQUIRE_TOKEN_ROUTES)
    +def test_gated_require_token_routes_accept_cookie_session(
    +    gated_app, method, path, body
    +):
    +    """Every ``_require_token`` route must clear auth for a logged-in caller.
    +
    +    Same root cause and fix as
    +    ``test_gated_require_token_endpoint_accepts_cookie_session`` — this just
    +    proves the fix covers the whole class, not only ``agent-plugins/install``.
    +    """
    +    _complete_stub_login(gated_app)
    +    kwargs = {"json": body} if body is not None else {}
    +    r = gated_app.request(method.upper(), path, **kwargs)
    +    assert r.status_code != 401, (
    +        f"{method.upper()} {path} 401'd a cookie-authenticated request under "
    +        f"the OAuth gate — _require_token still rejecting a valid session. "
    +        f"Body: {r.text}"
    +    )
    +
    +
     def test_login_unknown_provider_returns_404(gated_app):
         r = gated_app.get("/auth/login?provider=nonexistent", follow_redirects=False)
         assert r.status_code == 404
    ```
- **#55220: fix(dashboard): use fetchJSON auth in revealEnvVar for gated mode**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#51156`, `#51156`, `#51156`
- **#55221: fix(dashboard): inject placeholder session token in gated mode so reveal works**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`, `#51156`, `#55220`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#59663: fix(dashboard): inject session token in gated index.html so model-provider OAuth works under basic_auth (#59274)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`, `#59274`
- **#35500: fix(dashboard): skip legacy token check in gated mode for _require_token endpoints**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `63a421d4c0e053f9b3241433941bcbca035ad222`, `#42578`
- **#35589: fix(dashboard): honor OAuth sessions for plugin endpoints**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `63a421d4c0e053f9b3241433941bcbca035ad222`, `#35500`, `#42578`
- **#39319: fix(dashboard): honor gated OAuth sessions in token guard**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#42578`, `#42578`
- **#42148: fix(dashboard): bypass _require_token in gated mode to trust cookie auth**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `63a421d4c0e053f9b3241433941bcbca035ad222`, `#42578`, `#42139`, `#42578`, `#55210`
- **#58211: fix(dashboard): allow provider oauth with cookie auth**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`

## Fix status

- Merged to main in PR #34793 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-10`.
- Subsystem: `dashboard`
