Files
hermes-agent/web/src/plugins/sdk.d.ts
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

161 lines
6.9 KiB
TypeScript

/**
* 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 {};