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.
This commit is contained in:
Ben
2026-06-04 09:17:59 +10:00
committed by Teknium
parent 1c88360fed
commit a6e47314f9
8 changed files with 501 additions and 95 deletions

View File

@ -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("<status>: <body>") 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 }) {

View File

@ -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 <a href> 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 <a href> 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) {

View File

@ -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()

View File

@ -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):

View File

@ -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/<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)
)

View File

@ -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<Response> {
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<string, string>,
): Promise<string> {
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<StatusResponse>("/api/status"),
/**

View File

@ -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: {

160
web/src/plugins/sdk.d.ts vendored Normal file
View File

@ -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<string,
* ComponentType>`` 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("<status>: <body>")`` on non-2xx, and triggers the global
* 401 → /login redirect in gated mode. Use for all JSON plugin endpoints.
*/
export type FetchJSON = <T = unknown>(
url: string,
init?: RequestInit,
options?: { allowUnauthorized?: boolean },
) => Promise<T>;
/**
* 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<Response>;
/**
* 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<string, string>,
) => Promise<string>;
/** 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<Record<string, never>>): 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<T>``, 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<string, (...args: never[]) => 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<never>`` accepts any component regardless of its prop
* requirements (props are contravariant).
*/
components: Record<string, ComponentType<never>>;
utils: {
cn: (...classes: Array<string | false | null | undefined>) => 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 {};