mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
feat(dashboard): support serving under URL prefix via X-Forwarded-Prefix
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
This commit is contained in:
parent
6769060ae2
commit
52e2777821
4 changed files with 98 additions and 11 deletions
|
|
@ -52,7 +52,7 @@ from gateway.status import get_running_pid, read_runtime_status
|
|||
try:
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
|
|
@ -3308,12 +3308,42 @@ async def events_ws(ws: WebSocket) -> None:
|
|||
_event_channels.pop(channel, None)
|
||||
|
||||
|
||||
def _normalise_prefix(raw: Optional[str]) -> str:
|
||||
"""Normalise an X-Forwarded-Prefix header value.
|
||||
|
||||
Returns a string like ``"/hermes"`` (no trailing slash) or ``""`` when
|
||||
no prefix is set / the header is malformed. We deliberately reject
|
||||
anything containing ``..`` or non-printable bytes so a hostile proxy
|
||||
can't inject HTML via the prefix.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
p = raw.strip()
|
||||
if not p:
|
||||
return ""
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
p = p.rstrip("/")
|
||||
if "//" in p or ".." in p or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")):
|
||||
return ""
|
||||
if len(p) > 64:
|
||||
return ""
|
||||
return p
|
||||
|
||||
|
||||
def mount_spa(application: FastAPI):
|
||||
"""Mount the built SPA. Falls back to index.html for client-side routing.
|
||||
|
||||
The session token is injected into index.html via a ``<script>`` tag so
|
||||
the SPA can authenticate against protected API endpoints without a
|
||||
separate (unauthenticated) token-dispensing endpoint.
|
||||
|
||||
When served behind a path-prefix reverse proxy (e.g.
|
||||
``mission-control.tilos.com/hermes/*`` -> local Caddy -> :9119), the
|
||||
proxy injects ``X-Forwarded-Prefix: /hermes`` on every request. We
|
||||
rewrite the served ``index.html`` so absolute asset URLs (``/assets/...``)
|
||||
and the SPA's runtime ``__HERMES_BASE_PATH__`` honour that prefix
|
||||
without rebuilding the bundle.
|
||||
"""
|
||||
if not WEB_DIST.exists():
|
||||
@application.get("/{full_path:path}")
|
||||
|
|
@ -3326,24 +3356,62 @@ def mount_spa(application: FastAPI):
|
|||
|
||||
_index_path = WEB_DIST / "index.html"
|
||||
|
||||
def _serve_index():
|
||||
"""Return index.html with the session token injected."""
|
||||
def _serve_index(prefix: str = ""):
|
||||
"""Return index.html with the session token + base-path injected.
|
||||
|
||||
``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``)
|
||||
or empty string when served at root.
|
||||
"""
|
||||
html = _index_path.read_text()
|
||||
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
|
||||
token_script = (
|
||||
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};</script>"
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
||||
f'window.__HERMES_BASE_PATH__="{prefix}";</script>'
|
||||
)
|
||||
if prefix:
|
||||
# Rewrite absolute asset URLs baked into the Vite build so the
|
||||
# browser fetches them through the same proxy prefix.
|
||||
html = html.replace('href="/assets/', f'href="{prefix}/assets/')
|
||||
html = html.replace('src="/assets/', f'src="{prefix}/assets/')
|
||||
html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"')
|
||||
html = html.replace('href="/fonts/', f'href="{prefix}/fonts/')
|
||||
html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/')
|
||||
html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/')
|
||||
html = html.replace("</head>", f"{token_script}</head>", 1)
|
||||
return HTMLResponse(
|
||||
html,
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
|
||||
# When served behind a path-prefix proxy, the built CSS contains
|
||||
# absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references.
|
||||
# Browsers resolve those against the document origin, which means
|
||||
# under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...``
|
||||
# (the MC Pages app), not the Hermes backend. Intercept CSS asset
|
||||
# requests BEFORE the StaticFiles mount and rewrite the absolute paths
|
||||
# when a prefix is in play.
|
||||
@application.get("/assets/{filename}.css")
|
||||
async def serve_css(filename: str, request: Request):
|
||||
css_path = WEB_DIST / "assets" / f"{filename}.css"
|
||||
if not css_path.is_file() or not css_path.resolve().is_relative_to(
|
||||
WEB_DIST.resolve()
|
||||
):
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
||||
css = css_path.read_text()
|
||||
if prefix:
|
||||
for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"):
|
||||
css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}")
|
||||
css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}")
|
||||
css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}")
|
||||
return Response(content=css, media_type="text/css")
|
||||
|
||||
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||
|
||||
@application.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
async def serve_spa(full_path: str, request: Request):
|
||||
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
||||
file_path = WEB_DIST / full_path
|
||||
# Prevent path traversal via url-encoded sequences (%2e%2e/)
|
||||
if (
|
||||
|
|
@ -3353,7 +3421,7 @@ def mount_spa(application: FastAPI):
|
|||
and file_path.is_file()
|
||||
):
|
||||
return FileResponse(file_path)
|
||||
return _serve_index()
|
||||
return _serve_index(prefix)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,4 +1,21 @@
|
|||
const BASE = "";
|
||||
// The dashboard can be served either at the root of its host (e.g.
|
||||
// https://kanban.tilos.com/) or under a URL prefix when reverse-proxied
|
||||
// (e.g. https://mission-control.tilos.com/hermes/). The Python backend
|
||||
// injects ``window.__HERMES_BASE_PATH__`` into index.html based on the
|
||||
// incoming ``X-Forwarded-Prefix`` header so the SPA can address its own
|
||||
// ``/api/...`` and ``/dashboard-plugins/...`` URLs correctly without a
|
||||
// rebuild. Empty string means "served at root".
|
||||
function readBasePath(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const raw = window.__HERMES_BASE_PATH__ ?? "";
|
||||
if (!raw) return "";
|
||||
// Normalise: ensure leading slash, strip trailing slash.
|
||||
const withLead = raw.startsWith("/") ? raw : `/${raw}`;
|
||||
return withLead.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export const HERMES_BASE_PATH = readBasePath();
|
||||
const BASE = HERMES_BASE_PATH;
|
||||
|
||||
import type { DashboardTheme } from "@/themes/types";
|
||||
|
||||
|
|
@ -7,6 +24,7 @@ import type { DashboardTheme } from "@/themes/types";
|
|||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_BASE_PATH__?: string;
|
||||
}
|
||||
}
|
||||
let _sessionToken: string | null = null;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ import { SystemActionsProvider } from "./contexts/SystemActions";
|
|||
import { I18nProvider } from "./i18n";
|
||||
import { exposePluginSDK } from "./plugins";
|
||||
import { ThemeProvider } from "./themes";
|
||||
import { HERMES_BASE_PATH } from "./lib/api";
|
||||
|
||||
// Expose the plugin SDK before rendering so plugins loaded via <script>
|
||||
// can access React, components, etc. immediately.
|
||||
exposePluginSDK();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={HERMES_BASE_PATH || undefined}>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<SystemActionsProvider>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { api, HERMES_BASE_PATH } from "@/lib/api";
|
||||
import type { PluginManifest, RegisteredPlugin } from "./types";
|
||||
import {
|
||||
getPluginComponent,
|
||||
|
|
@ -43,7 +43,7 @@ export function usePlugins() {
|
|||
for (const manifest of manifests) {
|
||||
// Inject CSS if specified.
|
||||
if (manifest.css) {
|
||||
const cssUrl = `/dashboard-plugins/${manifest.name}/${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";
|
||||
|
|
@ -55,7 +55,7 @@ export function usePlugins() {
|
|||
// 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 = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
||||
const baseUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
||||
const scriptSrc = import.meta.env.DEV
|
||||
? `${baseUrl}?hermes_dv=${Date.now()}`
|
||||
: baseUrl;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue