mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-14 04:02:26 +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:
|
try:
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
@ -3308,12 +3308,42 @@ async def events_ws(ws: WebSocket) -> None:
|
||||||
_event_channels.pop(channel, 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):
|
def mount_spa(application: FastAPI):
|
||||||
"""Mount the built SPA. Falls back to index.html for client-side routing.
|
"""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 session token is injected into index.html via a ``<script>`` tag so
|
||||||
the SPA can authenticate against protected API endpoints without a
|
the SPA can authenticate against protected API endpoints without a
|
||||||
separate (unauthenticated) token-dispensing endpoint.
|
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():
|
if not WEB_DIST.exists():
|
||||||
@application.get("/{full_path:path}")
|
@application.get("/{full_path:path}")
|
||||||
|
|
@ -3326,24 +3356,62 @@ def mount_spa(application: FastAPI):
|
||||||
|
|
||||||
_index_path = WEB_DIST / "index.html"
|
_index_path = WEB_DIST / "index.html"
|
||||||
|
|
||||||
def _serve_index():
|
def _serve_index(prefix: str = ""):
|
||||||
"""Return index.html with the session token injected."""
|
"""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()
|
html = _index_path.read_text()
|
||||||
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
|
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
|
||||||
token_script = (
|
token_script = (
|
||||||
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
|
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)
|
html = html.replace("</head>", f"{token_script}</head>", 1)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
html,
|
html,
|
||||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
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.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||||
|
|
||||||
@application.get("/{full_path:path}")
|
@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
|
file_path = WEB_DIST / full_path
|
||||||
# Prevent path traversal via url-encoded sequences (%2e%2e/)
|
# Prevent path traversal via url-encoded sequences (%2e%2e/)
|
||||||
if (
|
if (
|
||||||
|
|
@ -3353,7 +3421,7 @@ def mount_spa(application: FastAPI):
|
||||||
and file_path.is_file()
|
and file_path.is_file()
|
||||||
):
|
):
|
||||||
return FileResponse(file_path)
|
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";
|
import type { DashboardTheme } from "@/themes/types";
|
||||||
|
|
||||||
|
|
@ -7,6 +24,7 @@ import type { DashboardTheme } from "@/themes/types";
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__HERMES_SESSION_TOKEN__?: string;
|
__HERMES_SESSION_TOKEN__?: string;
|
||||||
|
__HERMES_BASE_PATH__?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _sessionToken: string | null = null;
|
let _sessionToken: string | null = null;
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,14 @@ import { SystemActionsProvider } from "./contexts/SystemActions";
|
||||||
import { I18nProvider } from "./i18n";
|
import { I18nProvider } from "./i18n";
|
||||||
import { exposePluginSDK } from "./plugins";
|
import { exposePluginSDK } from "./plugins";
|
||||||
import { ThemeProvider } from "./themes";
|
import { ThemeProvider } from "./themes";
|
||||||
|
import { HERMES_BASE_PATH } from "./lib/api";
|
||||||
|
|
||||||
// Expose the plugin SDK before rendering so plugins loaded via <script>
|
// Expose the plugin SDK before rendering so plugins loaded via <script>
|
||||||
// can access React, components, etc. immediately.
|
// can access React, components, etc. immediately.
|
||||||
exposePluginSDK();
|
exposePluginSDK();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<BrowserRouter>
|
<BrowserRouter basename={HERMES_BASE_PATH || undefined}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<SystemActionsProvider>
|
<SystemActionsProvider>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
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 type { PluginManifest, RegisteredPlugin } from "./types";
|
||||||
import {
|
import {
|
||||||
getPluginComponent,
|
getPluginComponent,
|
||||||
|
|
@ -43,7 +43,7 @@ export function usePlugins() {
|
||||||
for (const manifest of manifests) {
|
for (const manifest of manifests) {
|
||||||
// Inject CSS if specified.
|
// Inject CSS if specified.
|
||||||
if (manifest.css) {
|
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}"]`)) {
|
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
|
||||||
const link = document.createElement("link");
|
const link = document.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
|
|
@ -55,7 +55,7 @@ export function usePlugins() {
|
||||||
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
|
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
|
||||||
// in-memory registry while the browser would otherwise never
|
// in-memory registry while the browser would otherwise never
|
||||||
// re-execute a previously cached <script> URL.
|
// 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
|
const scriptSrc = import.meta.env.DEV
|
||||||
? `${baseUrl}?hermes_dv=${Date.now()}`
|
? `${baseUrl}?hermes_dv=${Date.now()}`
|
||||||
: baseUrl;
|
: baseUrl;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue