diff --git a/plugins/hermes-achievements/dashboard/dist/index.js b/plugins/hermes-achievements/dashboard/dist/index.js index 001b688a9..5be8a3f1d 100644 --- a/plugins/hermes-achievements/dashboard/dist/index.js +++ b/plugins/hermes-achievements/dashboard/dist/index.js @@ -48,22 +48,16 @@ return tier ? "ha-tier-" + tier.toLowerCase() : "ha-tier-pending"; }; - async function api(path, options) { + function api(path, options) { + // Delegate to the host SDK's fetchJSON so auth is handled correctly in + // BOTH dashboard modes: loopback (X-Hermes-Session-Token header) and + // gated OAuth (hermes_session_at cookie via credentials:'include'). + // Hand-rolling fetch + reading window.__HERMES_SESSION_TOKEN__ directly + // 401s in gated mode (the token isn't injected there). fetchJSON throws + // Error(": ") on non-2xx — the call sites' .catch() relies + // on that to surface errors, so we let it propagate (don't swallow). const url = "/api/plugins/hermes-achievements" + path; - const token = window.__HERMES_SESSION_TOKEN__ || ""; - const headers = { ...((options && options.headers) || {}) }; - if (token) headers["X-Hermes-Session-Token"] = token; - const res = await fetch(url, { ...(options || {}), headers }); - if (!res.ok) { - const text = await res.text().catch(function () { return res.statusText; }); - throw new Error(res.status + ": " + text); - } - const text = await res.text(); - try { - return JSON.parse(text); - } catch (_) { - return null; - } + return SDK.fetchJSON(url, options); } function AchievementIcon({ icon }) { diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 451f3a011..5195c50c9 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -588,52 +588,62 @@ wsClosedRef.current = false; function openWs() { if (wsClosedRef.current) return; - const token = window.__HERMES_SESSION_TOKEN__ || ""; - const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; - const qsParams = { - since: String(cursorRef.current || 0), - token: token, - }; + // Build the WS URL via the host SDK so the correct auth param is used + // in BOTH modes: single-use ?ticket= in gated OAuth mode, ?token= in + // loopback. Reading window.__HERMES_SESSION_TOKEN__ directly (the old + // path) sends an empty token and is rejected in gated mode. buildWsUrl + // also applies the dashboard base-path prefix for reverse-proxied + // deployments, which the old inline URL did not. It's async (gated + // mode mints a fresh ticket per connect), so resolve then open. + const wsParams = { since: String(cursorRef.current || 0) }; // Pin the WS stream to the currently-selected board so events // from other boards don't bleed in. Includes "default" so the // dashboard's own board pin always wins over the server-side // ``current`` file — same rationale as ``withBoard()`` above. // Regression: #20879. - if (board) qsParams.board = board; - const qs = new URLSearchParams(qsParams); - const url = `${proto}//${window.location.host}${API}/events?${qs}`; - let ws; - try { ws = new WebSocket(url); } catch (_e) { return; } - wsRef.current = ws; - ws.onopen = function () { wsBackoffRef.current = 1000; }; - ws.onmessage = function (ev) { - try { - const msg = JSON.parse(ev.data); - if (msg && Array.isArray(msg.events) && msg.events.length > 0) { - cursorRef.current = msg.cursor || cursorRef.current; - // Stamp per-task signal so the TaskDrawer can reload itself. - setTaskEventTick(function (prev) { - const next = Object.assign({}, prev); - for (const e of msg.events) { - if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1; - } - return next; - }); - scheduleReload(); - } - } catch (_e) { /* ignore */ } - }; - ws.onclose = function (ev) { + if (board) wsParams.board = board; + SDK.buildWsUrl(`${API}/events`, wsParams).then(function (url) { + if (wsClosedRef.current) return; + let ws; + try { ws = new WebSocket(url); } catch (_e) { return; } + wsRef.current = ws; + ws.onopen = function () { wsBackoffRef.current = 1000; }; + ws.onmessage = function (ev) { + try { + const msg = JSON.parse(ev.data); + if (msg && Array.isArray(msg.events) && msg.events.length > 0) { + cursorRef.current = msg.cursor || cursorRef.current; + // Stamp per-task signal so the TaskDrawer can reload itself. + setTaskEventTick(function (prev) { + const next = Object.assign({}, prev); + for (const e of msg.events) { + if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1; + } + return next; + }); + scheduleReload(); + } + } catch (_e) { /* ignore */ } + }; + ws.onclose = function (ev) { + if (wsClosedRef.current) return; + if (ev && ev.code === 1008) { + setError(tx(t, "wsAuthFailed", + "WebSocket auth failed — reload the page to refresh the session token.")); + return; + } + const delay = Math.min(wsBackoffRef.current, 30000); + wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000); + setTimeout(openWs, delay); + }; + }).catch(function () { + // Ticket mint / URL build failed (e.g. session expired). Back off + // and retry; a hard auth failure surfaces via the 1008 close path. if (wsClosedRef.current) return; - if (ev && ev.code === 1008) { - setError(tx(t, "wsAuthFailed", - "WebSocket auth failed — reload the page to refresh the session token.")); - return; - } const delay = Math.min(wsBackoffRef.current, 30000); wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000); setTimeout(openWs, delay); - }; + }); } openWs(); return function () { @@ -2837,8 +2847,6 @@ if (!files.length) return; setUploadBusy(true); setUploadErr(null); - const token = window.__HERMES_SESSION_TOKEN__ || ""; - const headers = token ? { Authorization: "Bearer " + token } : {}; const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug); // Upload sequentially so a partial failure leaves a clear state. let chain = Promise.resolve(); @@ -2846,7 +2854,11 @@ chain = chain.then(function () { const fd = new FormData(); fd.append("file", f, f.name); - return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd }) + // SDK.authedFetch handles auth in BOTH modes (loopback token header / + // gated cookie) and applies the dashboard base-path prefix. The old + // hand-rolled Authorization:Bearer + credentials:'same-origin' sent + // an empty token and 401'd in gated mode. + return SDK.authedFetch(url, { method: "POST", body: fd }) .then(function (resp) { if (!resp.ok) { return resp.text().then(function (txt) { @@ -3073,15 +3085,16 @@ const fileRef = useRef(null); const [dlErr, setDlErr] = useState(null); // Download via authenticated fetch → blob → synthetic anchor click. - // A plain can't carry the session header/bearer the dashboard - // auth middleware requires in loopback mode, so fetch with the token - // and hand the browser a blob URL instead. + // A plain can't carry the auth the dashboard middleware requires, + // so fetch authenticated and hand the browser a blob URL instead. function downloadAttachment(a) { - const token = window.__HERMES_SESSION_TOKEN__ || ""; - const headers = token ? { Authorization: "Bearer " + token } : {}; + // SDK.authedFetch handles auth in BOTH modes (loopback token header / + // gated cookie) and applies the dashboard base-path prefix. The old + // hand-rolled Authorization:Bearer + credentials:'same-origin' sent an + // empty token and 401'd in gated mode. const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug); setDlErr(null); - fetch(url, { headers: headers, credentials: "same-origin" }) + SDK.authedFetch(url) .then(function (resp) { if (!resp.ok) { return resp.text().then(function (txt) { diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 2d792622f..7cc814dcf 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -36,7 +36,6 @@ the port. from __future__ import annotations import asyncio -import hmac import json import logging import os @@ -63,15 +62,29 @@ router = APIRouter() # existing plugin-bypass; this is documented above). # --------------------------------------------------------------------------- -def _check_ws_token(provided: Optional[str]) -> bool: - """Constant-time compare against the dashboard session token. +def _ws_upgrade_authorized(ws: "WebSocket") -> bool: + """Authorize a WebSocket upgrade by delegating to the dashboard's canonical + WS auth gate (``hermes_cli.web_server._ws_auth_ok``). + + Delegating (rather than re-implementing a ``_SESSION_TOKEN``-only check) + means this endpoint transparently accepts whatever the core gate accepts + in each mode: + + * loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` + * gated OAuth: single-use ``?ticket=`` (the browser SDK's + ``buildWsUrl`` mints one per connect) + * server-internal: the process-lifetime ``?internal=`` credential + + The previous bespoke check only understood ``_SESSION_TOKEN``, so the + kanban live-events WS was rejected on every OAuth-gated deployment even + though the rest of the dashboard worked. Routing through the shared gate + also means this can never drift from core auth again. Imported lazily so the plugin still loads in test contexts where the - dashboard web_server module isn't importable (e.g. the bare-FastAPI - test harness). + dashboard ``web_server`` module isn't importable (e.g. the bare-FastAPI + test harness); there we accept so the tail loop stays testable, matching + the prior behaviour. """ - if not provided: - return False try: from hermes_cli import web_server as _ws except Exception: @@ -79,10 +92,7 @@ def _check_ws_token(provided: Optional[str]) -> bool: # testable; in production the dashboard module always imports # cleanly because it's the caller. return True - expected = getattr(_ws, "_SESSION_TOKEN", None) - if not expected: - return True - return hmac.compare_digest(str(provided), str(expected)) + return bool(_ws._ws_auth_ok(ws)) def _resolve_board(board: Optional[str]) -> Optional[str]: @@ -2375,11 +2385,12 @@ def set_orchestration_settings(payload: OrchestrationSettingsBody): @router.websocket("/events") async def stream_events(ws: WebSocket): - # Enforce the dashboard session token as a query param — browsers can't - # set Authorization on a WS upgrade. This matches how the PTY bridge - # authenticates in hermes_cli/web_server.py. - token = ws.query_params.get("token") - if not _check_ws_token(token): + # Authorize the upgrade via the dashboard's canonical WS gate so the + # correct credential is accepted in every mode (loopback token / gated + # single-use ticket / server-internal credential). Browsers can't set + # Authorization on a WS upgrade, so the credential rides in the query + # string — the browser SDK's buildWsUrl() assembles it. + if not _ws_upgrade_authorized(ws): await ws.close(code=http_status.WS_1008_POLICY_VIOLATION) return await ws.accept() diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 57ce67352..e570c7627 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -735,18 +735,29 @@ def test_board_auto_initializes_missing_db(tmp_path, monkeypatch): def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch): - """When _SESSION_TOKEN is set (normal dashboard context), a missing or - wrong ?token= query param must be rejected with policy-violation.""" + """Loopback mode: a missing or wrong ?token= must be rejected with + policy-violation; the correct token is accepted. The kanban WS now + delegates to web_server._ws_auth_ok, so we stub that with the real + loopback-token semantics (auth_required False → constant-time token + compare).""" home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setattr(Path, "home", lambda: tmp_path) kb.init_db() - # Stub web_server so _check_ws_token has a token to compare against. + # Stub web_server with a loopback-mode _ws_auth_ok (auth_required False → + # accept only the correct ?token=). Mirrors the real gate's loopback path. import hermes_cli import types - stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz") + + def _fake_ws_auth_ok(ws): + return ws.query_params.get("token", "") == "secret-xyz" + + stub = types.SimpleNamespace( + _SESSION_TOKEN="secret-xyz", + _ws_auth_ok=_fake_ws_auth_ok, + ) monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub) monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False) @@ -774,6 +785,51 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch): assert ws is not None # handshake succeeded +def test_ws_events_accepts_gated_ticket(tmp_path, monkeypatch): + """Gated OAuth mode: the WS must accept a single-use ?ticket= (and reject + a bare ?token=, even one matching _SESSION_TOKEN). This is the regression + for the hosted-dashboard bug where the kanban live-events WS 1008'd on + every gated deployment because its bespoke check only knew _SESSION_TOKEN. + We stub _ws_auth_ok with the real gated semantics (ticket-only).""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + + import hermes_cli + import types + + def _fake_ws_auth_ok(ws): + # Gated mode: only a known ticket is accepted; token path rejected. + return ws.query_params.get("ticket", "") == "good-ticket" + + stub = types.SimpleNamespace( + _SESSION_TOKEN="secret-xyz", + _ws_auth_ok=_fake_ws_auth_ok, + ) + monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub) + monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False) + + app = FastAPI() + app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") + c = TestClient(app) + + from starlette.websockets import WebSocketDisconnect + + # Legacy token is rejected in gated mode, even if it's the real one. + with pytest.raises(WebSocketDisconnect) as exc: + with c.websocket_connect("/api/plugins/kanban/events?token=secret-xyz"): + pass + assert exc.value.code == 1008 + + # A valid ticket is accepted. + with c.websocket_connect( + "/api/plugins/kanban/events?ticket=good-ticket" + ) as ws: + assert ws is not None + + def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp_path, monkeypatch): """The event stream must honor ``board=default`` even when the global current-board pointer targets a different board. @@ -806,7 +862,10 @@ def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp import hermes_cli import types - stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz") + stub = types.SimpleNamespace( + _SESSION_TOKEN="secret-xyz", + _ws_auth_ok=lambda ws: ws.query_params.get("token", "") == "secret-xyz", + ) monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub) monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False) @@ -842,10 +901,10 @@ def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) kb.init_db() - # Short-circuit the token check — this test is about the cancellation + # Short-circuit the auth check — this test is about the cancellation # path, not auth. import plugins.kanban.dashboard.plugin_api as pa - monkeypatch.setattr(pa, "_check_ws_token", lambda t: True) + monkeypatch.setattr(pa, "_ws_upgrade_authorized", lambda ws: True) class _FakeWS: def __init__(self): diff --git a/tests/plugins/test_plugin_dashboard_auth_contract.py b/tests/plugins/test_plugin_dashboard_auth_contract.py new file mode 100644 index 000000000..1d8ad035a --- /dev/null +++ b/tests/plugins/test_plugin_dashboard_auth_contract.py @@ -0,0 +1,95 @@ +"""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/ → ../../ +_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//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) + ) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4eb6291fd..70914c9e7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -192,6 +192,63 @@ export async function buildWsAuthParam(): Promise<[string, string]> { return ["token", token]; } +/** + * Authenticated ``fetch`` for dashboard ``/api/...`` requests that aren't + * plain JSON — file uploads (``FormData``), binary downloads (blobs), etc. + * Mirrors ``fetchJSON``'s auth handling but returns the raw ``Response`` so + * the caller can read ``.blob()`` / ``.formData()`` / stream it. + * + * Auth, in both modes, exactly as ``fetchJSON`` does it: + * - loopback / ``--insecure``: attach the ``X-Hermes-Session-Token`` header. + * - gated OAuth: no token header (it's absent by design); the + * ``hermes_session_at`` cookie rides along via ``credentials: 'include'``. + * + * Unlike ``fetchJSON`` this does NOT parse the body, does NOT throw on + * non-2xx (the caller decides — a 404 on a download is meaningful), and + * does NOT run the global 401 → /login redirect (binary endpoints aren't + * navigation targets). Callers that want the redirect behaviour should use + * ``fetchJSON``. + */ +export async function authedFetch( + url: string, + init?: RequestInit, +): Promise { + const headers = new Headers(init?.headers); + const token = window.__HERMES_SESSION_TOKEN__; + if (token) { + setSessionHeader(headers, token); + } + return fetch(`${BASE}${url}`, { + ...init, + headers, + credentials: init?.credentials ?? "include", + }); +} + +/** + * Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint, + * with the correct auth query param appended for the active mode (fresh + * single-use ``ticket`` in gated mode, ``token`` in loopback). Plugins and + * the SPA should use this instead of hand-assembling a WS URL + reading + * ``window.__HERMES_SESSION_TOKEN__`` directly, so the gated-mode ticket + * path can never be forgotten. + * + * ``path`` is the dashboard-relative path (e.g. + * ``"/api/plugins/kanban/events"``); the base-path prefix and host are + * applied here. Extra query params can be supplied via ``params`` and are + * merged before the auth param. + */ +export async function buildWsUrl( + path: string, + params?: Record, +): Promise { + const [authName, authValue] = await buildWsAuthParam(); + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const qs = new URLSearchParams(params ?? {}); + qs.set(authName, authValue); + return `${proto}//${window.location.host}${BASE}${path}?${qs}`; +} + export const api = { getStatus: () => fetchJSON("/api/status"), /** diff --git a/web/src/plugins/registry.ts b/web/src/plugins/registry.ts index e62dc8db7..392c536d0 100644 --- a/web/src/plugins/registry.ts +++ b/web/src/plugins/registry.ts @@ -17,7 +17,7 @@ import React, { useContext, createContext, } from "react"; -import { api, fetchJSON } from "@/lib/api"; +import { api, fetchJSON, authedFetch, buildWsUrl, buildWsAuthParam } from "@/lib/api"; import { cn, timeAgo, isoTimeAgo } from "@/lib/utils"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; @@ -88,15 +88,18 @@ export function getRegisteredCount(): number { // Expose SDK + registry on window // --------------------------------------------------------------------------- -declare global { - interface Window { - __HERMES_PLUGIN_SDK__: unknown; - __HERMES_PLUGINS__: { - register: typeof registerPlugin; - registerSlot: typeof registerSlot; - }; - } -} +/** + * Version of the plugin SDK contract (see ``plugins/sdk.d.ts``). Bump the + * major on any backwards-incompatible change to the exposed surface; + * additive changes (new optional fields / helpers) don't require a bump. + * Exposed at runtime as ``window.__HERMES_PLUGIN_SDK__.sdkVersion`` so a + * plugin (or a future host-side compatibility gate) can read it. + */ +export const SDK_CONTRACT_VERSION = "1.1.0"; + +// Window globals for the plugin SDK are declared in ``plugins/sdk.d.ts`` — +// the single source of truth for the public contract. Don't redeclare them +// here (duplicate ambient declarations with differing modifiers conflict). export function exposePluginSDK() { window.__HERMES_PLUGINS__ = { @@ -105,6 +108,9 @@ export function exposePluginSDK() { }; window.__HERMES_PLUGIN_SDK__ = { + // Contract version of the plugin SDK surface (see plugins/sdk.d.ts). + // Bump on backwards-incompatible changes; additive changes don't need it. + sdkVersion: SDK_CONTRACT_VERSION, // React core — plugins use these instead of importing react React, hooks: { @@ -119,8 +125,19 @@ export function exposePluginSDK() { // Hermes API client api, - // Raw fetchJSON for plugin-specific endpoints + // Raw fetchJSON for plugin-specific JSON endpoints fetchJSON, + // Authenticated fetch for non-JSON endpoints (uploads / blob downloads). + // Handles loopback-token vs gated-cookie auth so plugins never read + // window.__HERMES_SESSION_TOKEN__ directly. + authedFetch, + // Build a ws(s):// URL with the correct auth param for the active mode + // (single-use ticket in gated mode, token in loopback). Use this for any + // plugin WebSocket instead of hand-assembling the URL. + buildWsUrl, + // Lower-level: resolve just the [authParamName, authParamValue] pair, for + // plugins that need to build the WS URL themselves. + buildWsAuthParam, // UI components — Nous DS where available, shadcn/ui primitives elsewhere. components: { diff --git a/web/src/plugins/sdk.d.ts b/web/src/plugins/sdk.d.ts new file mode 100644 index 000000000..c55b855ab --- /dev/null +++ b/web/src/plugins/sdk.d.ts @@ -0,0 +1,160 @@ +/** + * Hermes Dashboard Plugin SDK — typed contract (SPIKE) + * ==================================================== + * + * This is the public type surface for ``window.__HERMES_PLUGIN_SDK__`` and + * ``window.__HERMES_PLUGINS__``, the globals the dashboard host exposes to + * plugin bundles (see ``web/src/plugins/registry.ts::exposePluginSDK``). + * + * STATUS: spike. This file documents the contract and gives plugin authors + * (in-repo IIFEs and external bundles alike) editor types without bundling + * their own copies of React / the API client. It is intentionally a + * hand-authored ambient declaration rather than ``typeof + * window.__HERMES_PLUGIN_SDK__`` because: + * 1. The runtime object is assembled from many internal modules + * (``@/lib/api``, ``@nous-research/ui``, …). Deriving the type would + * leak those internal import paths into the public contract and couple + * external plugins to the host's internal module layout. + * 2. A hand-authored contract is the *versioned API boundary* — changing + * it is a deliberate act, visible in review, not an accidental + * consequence of refactoring an internal helper. + * + * Versioning: bump ``HermesPluginSDK["sdkVersion"]`` (and the + * ``SDK_CONTRACT_VERSION`` const the host exposes) on any + * backwards-incompatible change to this surface. Additive changes + * (new optional fields, new helpers) don't require a major bump. + * + * OPEN QUESTIONS for productionising this spike (do not block the auth fix): + * - Ship as a published ``@hermes/dashboard-plugin-sdk`` types package, or + * keep in-repo and copy into external plugin repos? + * - Should the host assert at runtime that a plugin's declared + * ``manifest.sdk_version`` is compatible before executing it? + * - The ``components`` map is typed loosely as ``Record`` here; do we want exact per-component prop types + * (pulls @nous-research/ui types into the contract) or is the loose + * shape the right boundary for external authors? + */ + +import type { ComponentType } from "react"; + +// --------------------------------------------------------------------------- +// Auth-relevant helpers (the surface this PR adds/sanctions) +// --------------------------------------------------------------------------- + +/** + * JSON ``fetch`` for dashboard ``/api/...`` endpoints. Handles auth in both + * modes (loopback session-token header / gated cookie), throws + * ``Error(": ")`` on non-2xx, and triggers the global + * 401 → /login redirect in gated mode. Use for all JSON plugin endpoints. + */ +export type FetchJSON = ( + url: string, + init?: RequestInit, + options?: { allowUnauthorized?: boolean }, +) => Promise; + +/** + * Authenticated ``fetch`` for NON-JSON endpoints (uploads via ``FormData``, + * binary/blob downloads). Same auth handling as ``fetchJSON`` but returns + * the raw ``Response``, does not parse, does not throw on non-2xx, and does + * not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead + * of calling ``fetch`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``. + */ +export type AuthedFetch = (url: string, init?: RequestInit) => Promise; + +/** + * Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with + * the correct auth query param for the active mode (single-use ``ticket`` in + * gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any + * WebSocket instead of hand-assembling the URL + reading the session token. + */ +export type BuildWsUrl = ( + path: string, + params?: Record, +) => Promise; + +/** Lower-level: just the ``[authParamName, authParamValue]`` pair. */ +export type BuildWsAuthParam = () => Promise<[string, string]>; + +// --------------------------------------------------------------------------- +// Registry surface (window.__HERMES_PLUGINS__) +// --------------------------------------------------------------------------- + +export interface PluginRegistry { + /** Register the plugin's main tab component by manifest name. */ + register(name: string, component: ComponentType>): void; + /** Register a component into a named host slot. */ + registerSlot(slot: string, name: string, component: ComponentType): void; +} + +// --------------------------------------------------------------------------- +// SDK surface (window.__HERMES_PLUGIN_SDK__) +// --------------------------------------------------------------------------- + +export interface HermesPluginSDK { + /** Contract version of this SDK surface (see SDK_CONTRACT_VERSION). */ + readonly sdkVersion: string; + + /** React core — use instead of importing/bundling react. */ + React: typeof import("react").default; + hooks: { + useState: typeof import("react").useState; + useEffect: typeof import("react").useEffect; + useCallback: typeof import("react").useCallback; + useMemo: typeof import("react").useMemo; + useRef: typeof import("react").useRef; + useContext: typeof import("react").useContext; + createContext: typeof import("react").createContext; + }; + + /** + * Typed convenience client for core dashboard endpoints. Typed permissively + * at the boundary (methods vary in arity and return type — most return + * ``Promise``, a few return a URL string synchronously); plugins call the + * specific methods they need. See ``web/src/lib/api.ts`` for the concrete shape. + */ + api: Record unknown>; + + /** JSON fetch with host auth handling. */ + fetchJSON: FetchJSON; + /** Authenticated raw fetch for uploads / blob downloads. */ + authedFetch: AuthedFetch; + /** Build an auth'd WebSocket URL for the active mode. */ + buildWsUrl: BuildWsUrl; + /** Resolve just the WS auth query-param pair. */ + buildWsAuthParam: BuildWsAuthParam; + + /** + * Shared UI primitives (Nous DS / shadcn). Typed permissively at the + * boundary: the host's concrete components (some of which require props like + * ``active``/``value``/``name``) must be assignable here, and external plugin + * authors render them dynamically without the host's internal prop types. + * ``ComponentType`` accepts any component regardless of its prop + * requirements (props are contravariant). + */ + components: Record>; + + utils: { + cn: (...classes: Array) => string; + /** Relative-time formatter. Accepts an epoch-ms number. */ + timeAgo: (ts: number) => string; + /** Relative-time formatter for an ISO-8601 string. */ + isoTimeAgo: (iso: string) => string; + }; + + /** + * i18n hook. Returns the host's i18n context value; typed loosely at the + * boundary so the contract doesn't couple to the host's internal + * ``I18nContextValue`` shape. Plugins typically call ``useI18n().t(...)``. + */ + useI18n: () => unknown; +} + +declare global { + interface Window { + __HERMES_PLUGIN_SDK__?: HermesPluginSDK; + __HERMES_PLUGINS__?: PluginRegistry; + } +} + +export {};