diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index a6af66bc9a..46786455ce 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -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 ``"
+ f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
+ f'window.__HERMES_BASE_PATH__="{prefix}";'
)
+ 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("", f"{token_script}", 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)
# ---------------------------------------------------------------------------
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 94d5b547d6..6568e979bc 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -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;
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 57a08b9634..e0d00fdf63 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -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