mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
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:
parent
1c88360fed
commit
a6e47314f9
8 changed files with 501 additions and 95 deletions
|
|
@ -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
160
web/src/plugins/sdk.d.ts
vendored
Normal 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 {};
|
||||
Loading…
Add table
Add a link
Reference in a new issue