Files
hermes-agent/tests/plugins/test_plugin_dashboard_auth_contract.py
Ben a6e47314f9 fix(dashboard): sanction plugin WS/upload auth via SDK helpers (gated mode)
Dashboard plugins (kanban, hermes-achievements) read
window.__HERMES_SESSION_TOKEN__ directly and hand-assembled WebSocket
URLs with ?token=. That works in loopback/--insecure mode but is
rejected on OAuth-gated deployments, where the session token is absent
and _ws_auth_ok only accepts single-use ?ticket= auth. The result was
401s on plugin REST calls and 1008/403 on the kanban live-events WS
whenever the dashboard ran behind OAuth (e.g. hosted Fly agents).

Make the plugin SDK the single sanctioned auth surface:

- web/src/lib/api.ts: add authedFetch() (raw Response for FormData
  uploads / blob downloads, token-or-cookie auth, no throw / no 401
  redirect) and buildWsUrl() (assembles a ws(s):// URL with the correct
  auth param for the active mode — fresh single-use ticket in gated
  mode, token in loopback).
- web/src/plugins/registry.ts: expose authedFetch, buildWsUrl,
  buildWsAuthParam, and sdkVersion on window.__HERMES_PLUGIN_SDK__;
  add SDK_CONTRACT_VERSION.
- web/src/plugins/sdk.d.ts: hand-authored typed contract for the
  plugin SDK + registry globals (single source of truth for the
  Window declarations).
- plugins/kanban + hermes-achievements dist bundles: stop reading the
  session token directly; route uploads/downloads through
  SDK.authedFetch and the live-events WS through SDK.buildWsUrl.
- plugins/kanban plugin_api.py: _ws_upgrade_authorized() delegates the
  /events WS upgrade to the canonical web_server._ws_auth_ok gate, so
  it transparently accepts loopback token / gated ticket / internal
  credential and can never drift from core auth again.
- tests: guard test asserting no plugin dist reads
  __HERMES_SESSION_TOKEN__ directly; kanban gated-ticket WS test.

Verified live on a gated staging Fly agent: kanban /events upgrades
101 with a minted ticket (ticket_len=43, ws_auth_ok=True) where the
old code got 403.
2026-06-03 16:59:36 -07:00

96 lines
3.9 KiB
Python

"""Guardrail: dashboard plugins must NOT read the session token directly.
The dashboard host exposes a sanctioned, gated-mode-aware auth surface on the
plugin SDK (``window.__HERMES_PLUGIN_SDK__``): ``fetchJSON`` (JSON REST),
``authedFetch`` (uploads / blob downloads), and ``buildWsUrl`` /
``buildWsAuthParam`` (WebSockets). These handle BOTH dashboard auth modes —
loopback (``X-Hermes-Session-Token`` header) and gated OAuth
(``hermes_session_at`` cookie / single-use ``?ticket=``).
Plugins that hand-roll ``fetch`` / ``WebSocket`` and read
``window.__HERMES_SESSION_TOKEN__`` directly send an empty token in gated mode
and 401/1008. That bug shipped in the kanban and achievements plugins and was
invisible until the dashboard ran gated on hosted Fly agents.
This test fails if any bundled plugin's frontend reads the token global
directly, forcing new/edited plugins through the SDK surface instead. It is
the enforcement half of the "single sanctioned auth surface" design — the SDK
helpers are the carrot, this test is the stick.
If you have a legitimate reason to reference the token name (e.g. a comment
explaining why NOT to use it), add the file to ``_ALLOWED_FILES`` with a note.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
# Repo root: tests/plugins/<this file> → ../../
_REPO_ROOT = Path(__file__).resolve().parents[2]
_PLUGINS_DIR = _REPO_ROOT / "plugins"
# The forbidden global. Reading it directly bypasses the gated-mode auth path.
_FORBIDDEN = "__HERMES_SESSION_TOKEN__"
# Files explicitly allowed to mention the token (none today). Map path →
# reason so the allowance is self-documenting if one is ever needed.
_ALLOWED_FILES: dict[str, str] = {}
def _plugin_frontend_bundles() -> list[Path]:
"""Every plugin-shipped JS bundle the dashboard loads into the browser."""
if not _PLUGINS_DIR.is_dir():
return []
# Plugin dashboards live at plugins/<name>/dashboard/dist/*.js
return sorted(_PLUGINS_DIR.glob("*/dashboard/dist/*.js"))
def test_there_are_plugin_bundles_to_check() -> None:
"""Sanity: the glob actually finds the bundles, so a future layout change
doesn't silently turn this guard into a no-op."""
bundles = _plugin_frontend_bundles()
names = {b.parent.parent.parent.name for b in bundles}
# kanban + hermes-achievements are bundled today; assert at least one is
# found so the guard can't pass vacuously.
assert bundles, "no plugin dashboard bundles found — glob/layout drift?"
assert names, "could not resolve plugin names from bundle paths"
@pytest.mark.parametrize(
"bundle",
_plugin_frontend_bundles(),
ids=lambda p: str(p.relative_to(_REPO_ROOT)),
)
def test_plugin_bundle_does_not_read_session_token(bundle: Path) -> None:
rel = str(bundle.relative_to(_REPO_ROOT))
text = bundle.read_text(encoding="utf-8", errors="replace")
if rel in _ALLOWED_FILES:
return # explicitly allowed (with a documented reason)
# Only flag CODE reads of the token global, not mentions in ``//`` comments
# (e.g. a comment explaining why the SDK helper is used instead). A line is
# a code read if it contains the global and the global appears before any
# ``//`` comment marker on that line.
offending: list[str] = []
for i, line in enumerate(text.splitlines(), start=1):
idx = line.find(_FORBIDDEN)
if idx == -1:
continue
comment_idx = line.find("//")
in_comment = comment_idx != -1 and comment_idx < idx
if not in_comment:
offending.append(f" {i}: {line.strip()}")
if not offending:
return
pytest.fail(
f"{rel} reads {_FORBIDDEN} directly — this bypasses gated-mode auth "
f"and 401/1008s on OAuth-gated dashboards. Use the plugin SDK instead: "
f"SDK.fetchJSON (JSON), SDK.authedFetch (uploads/downloads), or "
f"SDK.buildWsUrl (WebSockets). Offending lines:\n" + "\n".join(offending)
)