mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
The Hermes dashboard previously assumed it was served at the root of its host (e.g. https://kanban.tilos.com/). When mounted behind a path-prefix reverse proxy (e.g. https://mission-control.tilos.com/hermes/), the SPA 404'd because: - index.html shipped absolute /assets/index-*.js URLs - React Router had no basename - The plugin loader hit /dashboard-plugins/<name>/... at the root host - CSS in the bundle had absolute url(/fonts/...) references This patch makes the dashboard prefix-aware at runtime, no rebuild required. The proxy injects 'X-Forwarded-Prefix: /hermes' on every request and the Python server: - Rewrites href/src in served index.html to '${prefix}/assets/...' - Injects 'window.__HERMES_BASE_PATH__="${prefix}"' for the SPA to read - Rewrites url() refs in CSS at serve time The SPA reads window.__HERMES_BASE_PATH__ once at boot and: - Prefixes all /api/... fetches via api.ts - Prefixes all /dashboard-plugins/... script/css URLs in usePlugins - Sets <BrowserRouter basename={...}> so client-side routing works When no X-Forwarded-Prefix header is present, behavior is unchanged (empty prefix => serves at root, kanban.tilos.com keeps working). Refs: MC-AUTO-13
133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
/**
|
|
* usePlugins hook — discovers and loads dashboard plugins.
|
|
*
|
|
* 1. Fetches plugin manifests from GET /api/dashboard/plugins
|
|
* 2. Injects CSS <link> tags for plugins that declare css
|
|
* 3. Loads plugin JS bundles via <script> tags
|
|
* 4. Waits for plugins to call register() and resolves them
|
|
*/
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { api, HERMES_BASE_PATH } from "@/lib/api";
|
|
import type { PluginManifest, RegisteredPlugin } from "./types";
|
|
import {
|
|
getPluginComponent,
|
|
onPluginRegistered,
|
|
notifyPluginRegistry,
|
|
setPluginLoadError,
|
|
} from "./registry";
|
|
|
|
export function usePlugins() {
|
|
const [manifests, setManifests] = useState<PluginManifest[]>([]);
|
|
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const loadedScripts = useRef<Set<string>>(new Set());
|
|
|
|
// Fetch manifests on mount.
|
|
useEffect(() => {
|
|
api
|
|
.getPlugins()
|
|
.then((list) => {
|
|
setManifests(list);
|
|
if (list.length === 0) setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, []);
|
|
|
|
// Load plugin assets when manifests arrive.
|
|
useEffect(() => {
|
|
if (manifests.length === 0) return;
|
|
|
|
const injectedScripts: HTMLScriptElement[] = [];
|
|
|
|
for (const manifest of manifests) {
|
|
// Inject CSS if specified.
|
|
if (manifest.css) {
|
|
const cssUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.css}`;
|
|
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
|
|
const link = document.createElement("link");
|
|
link.rel = "stylesheet";
|
|
link.href = cssUrl;
|
|
document.head.appendChild(link);
|
|
}
|
|
}
|
|
|
|
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
|
|
// in-memory registry while the browser would otherwise never
|
|
// re-execute a previously cached <script> URL.
|
|
const baseUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
|
const scriptSrc = import.meta.env.DEV
|
|
? `${baseUrl}?hermes_dv=${Date.now()}`
|
|
: baseUrl;
|
|
if (!import.meta.env.DEV) {
|
|
if (loadedScripts.current.has(baseUrl)) continue;
|
|
loadedScripts.current.add(baseUrl);
|
|
}
|
|
|
|
const script = document.createElement("script");
|
|
script.setAttribute("data-hermes-plugin", manifest.name);
|
|
script.src = scriptSrc;
|
|
script.async = true;
|
|
// SRI integrity verification — defense against compromised plugin
|
|
// delivery. Plugin manifests can declare an integrity hash
|
|
// (e.g. "sha384-...") which the browser verifies before executing.
|
|
// Without this, a man-in-the-middle or compromised plugin server
|
|
// can substitute the JS bundle silently. Opt-in: when no integrity
|
|
// is declared in the manifest, behavior is unchanged.
|
|
if (manifest.integrity && typeof manifest.integrity === "string") {
|
|
script.integrity = manifest.integrity;
|
|
script.crossOrigin = "anonymous";
|
|
}
|
|
script.onerror = () => {
|
|
setPluginLoadError(manifest.name, "LOAD_FAILED");
|
|
console.warn(
|
|
`[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`,
|
|
);
|
|
};
|
|
script.onload = () => {
|
|
notifyPluginRegistry();
|
|
queueMicrotask(() => {
|
|
if (getPluginComponent(manifest.name)) return;
|
|
setPluginLoadError(manifest.name, "NO_REGISTER");
|
|
});
|
|
};
|
|
document.body.appendChild(script);
|
|
injectedScripts.push(script);
|
|
}
|
|
|
|
// Give plugins a moment to load and register, then stop loading state.
|
|
const timeout = setTimeout(() => setLoading(false), 2000);
|
|
return () => {
|
|
clearTimeout(timeout);
|
|
if (import.meta.env.DEV) {
|
|
for (const el of injectedScripts) {
|
|
el.remove();
|
|
}
|
|
}
|
|
};
|
|
}, [manifests]);
|
|
|
|
// Listen for plugin registrations and resolve them against manifests.
|
|
useEffect(() => {
|
|
function resolvePlugins() {
|
|
const resolved: RegisteredPlugin[] = [];
|
|
for (const manifest of manifests) {
|
|
const component = getPluginComponent(manifest.name);
|
|
if (component) {
|
|
resolved.push({ manifest, component });
|
|
}
|
|
}
|
|
setPlugins(resolved);
|
|
// If all plugins registered, stop loading early.
|
|
if (resolved.length === manifests.length && manifests.length > 0) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
resolvePlugins();
|
|
const unsub = onPluginRegistered(resolvePlugins);
|
|
return unsub;
|
|
}, [manifests]);
|
|
|
|
return { plugins, manifests, loading };
|
|
}
|