diff --git a/.dockerignore b/.dockerignore
index ec3d52f8141..d4a3573a4f0 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -66,12 +66,28 @@ runtime/
# ---------- Not needed inside the Docker image ----------
-# Desktop app source (Tauri/Electron); never installed in the container.
-# apps/shared is the dashboard↔desktop websocket helper and is linked from
-# web/package.json as a file: workspace dep — keep it in the build context.
+# Desktop app source (Tauri/Electron): only apps/shared and apps/desktop are
+# needed in the container.
+# - apps/shared is the dashboard↔desktop websocket helper, linked from
+# web/package.json as a file: workspace dep.
+# - apps/desktop is the desktop renderer, built browser-only inside the image
+# (`npm run build:web`) and served at /app (HERMES_APP_DIST). Its local
+# build outputs are re-excluded below so a developer's stale dist can't
+# overwrite the image-built one via `COPY . .` (same failure mode as
+# ui-tui/dist/ above), and release/ (packaged Electron apps, ~GBs) never
+# belongs in a build context.
apps/
!apps/shared/
!apps/shared/**
+!apps/desktop/
+!apps/desktop/**
+# Re-exclusions: !apps/desktop/** above re-includes ANYTHING under the tree —
+# including node_modules already excluded by the global rule at the top
+# (last matching rule wins). Each build output must be re-excluded here.
+apps/desktop/node_modules/
+apps/desktop/dist/
+apps/desktop/build/
+apps/desktop/release/
# Test suite — not shipped in production images
tests/
diff --git a/Dockerfile b/Dockerfile
index 6f957f77967..cd8d60625e1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -118,6 +118,7 @@ WORKDIR /opt/hermes
COPY package.json package-lock.json ./
COPY web/package.json web/
COPY ui-tui/package.json ui-tui/
+COPY apps/desktop/package.json apps/desktop/
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
# apps/shared/ is copied IN FULL because web/package.json references it as a
# `file:` workspace dependency (same pattern as hermes-ink above).
@@ -135,6 +136,14 @@ COPY apps/shared/ apps/shared/
# guards against a future regression if the source npm version changes.
ENV npm_config_install_links=false
+# `ELECTRON_SKIP_BINARY_DOWNLOAD=1`: the desktop workspace lists `electron` as
+# a devDependency, but the image only ever runs its *renderer* as a browser
+# bundle (`build:web` below) — the ~100MB Electron runtime binary would be
+# downloaded, never executed, and baked into the layer. Skipping the download
+# keeps the postinstall a no-op; node-pty's native compile still runs (the
+# gcc/make toolchain is already present for python-olm above).
+ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1
+
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
npm cache clean --force
@@ -188,8 +197,14 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
COPY web/ web/
COPY ui-tui/ ui-tui/
COPY apps/shared/ apps/shared/
+# The desktop renderer, built browser-only (no Electron packaging steps) and
+# served by the gateway at /app — HERMES_APP_DIST below points at this dist.
+# .dockerignore excludes any local dist/build/release so this build is always
+# from source.
+COPY apps/desktop/ apps/desktop/
RUN cd web && npm run build && \
- cd ../ui-tui && npm run build
+ cd ../ui-tui && npm run build && \
+ cd ../apps/desktop && npm run build:web
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
@@ -274,6 +289,13 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
# ---------- Runtime ----------
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
+# The desktop renderer web build (Layer above: `apps/desktop && npm run
+# build:web`), served by the gateway at /app. Explicitly opting in via this
+# env var is the ONLY thing that mounts /app — web_server.py has no path
+# fallback — so the browser UI is a fact of the image, never of a source
+# checkout. The dist survives `COPY . .` because .dockerignore excludes
+# apps/desktop/dist from the build context.
+ENV HERMES_APP_DIST=/opt/hermes/apps/desktop/dist
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
@@ -282,13 +304,14 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
#
# Why this is required (not just an optimization): the root package-lock.json
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
-# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
-# never `npm install`ed here). So the actualized node_modules permanently
-# disagrees with the canonical lock, _tui_need_npm_install() returns True on
-# every launch, and the runtime `npm install` it triggers (a) can never
-# converge against the partial monorepo and (b) races itself across concurrent
-# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
-# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
+# but the image's npm install only actualizes the workspaces present in the
+# build context (apps/desktop joined for the /app renderer build; any other
+# apps/* stay excluded). So the actualized node_modules can disagree with the
+# canonical lock, _tui_need_npm_install() returns True on every launch, and
+# the runtime `npm install` it triggers (a) can never converge against the
+# partial monorepo and (b) races itself across concurrent embedded-chat
+# (/api/pty) connections → ENOTEMPTY → the chat tab dies with a 502 /
+# "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
# check. (A separate launcher hardening is tracked independently.)
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 05e193790bf..b8bbc85490d 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -20,6 +20,7 @@
"start": "npm run build && electron .",
"prebuild": "tsc -b . --clean",
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
+ "build:web": "tsc -b tsconfig.electron.json && npm run typecheck && vite build",
"postbuild": "node scripts/assert-dist-built.mjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
diff --git a/apps/desktop/src/app/settings/about-settings.tsx b/apps/desktop/src/app/settings/about-settings.tsx
index fa0ca76a2c3..921f8ac2c82 100644
--- a/apps/desktop/src/app/settings/about-settings.tsx
+++ b/apps/desktop/src/app/settings/about-settings.tsx
@@ -65,6 +65,10 @@ export function AboutSettings() {
const behind = status?.behind ?? 0
const supported = status?.supported !== false
const applying = apply.applying || apply.stage === 'restart'
+ // On the web bridge there is no self-updater (the instance updates
+ // server-side) — hide the update controls entirely rather than render a
+ // check button that can never do anything. Version + release notes remain.
+ const hasUpdater = Boolean(window.hermesDesktop?.updates)
const handleCheck = async () => {
setJustChecked(false)
@@ -106,75 +110,79 @@ export function AboutSettings() {
-
+ {hasUpdater && (
+ <>
+
-
-
- {statusTone === 'available' ? (
-
- ) : statusTone === 'error' ? null : (
-
- )}
-
-
{statusLine}
-
- {a.lastChecked(relativeTime(status?.fetchedAt, a))}
- {justChecked && !checking ? a.justNowSuffix : ''}
-
-
-
-
-
-
-
-
+
+ >
+ )}
diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx
index 9c7c4dd0b4e..2a9d40d604d 100644
--- a/apps/desktop/src/app/settings/index.tsx
+++ b/apps/desktop/src/app/settings/index.tsx
@@ -8,6 +8,7 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
+import { isWebHost } from '@/web-bridge'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayIconButton } from '../overlays/overlay-chrome'
@@ -156,13 +157,19 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
label: t.settings.nav.providers,
onSelect: () => setActiveView('providers')
},
- {
- active: activeView === 'gateway',
- icon: Globe,
- id: 'gateway',
- label: t.settings.nav.gateway,
- onSelect: () => setActiveView('gateway')
- },
+ // On web the connection IS the serving origin — there is nothing to
+ // configure, so the Gateway panel is absent (RFC D7 stubbed-surface gating).
+ ...(isWebHost()
+ ? []
+ : [
+ {
+ active: activeView === 'gateway',
+ icon: Globe,
+ id: 'gateway',
+ label: t.settings.nav.gateway,
+ onSelect: () => setActiveView('gateway')
+ }
+ ]),
{
active: activeView === 'keys',
children: [
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index d109a53d3e9..0a7989466f9 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -41,7 +41,7 @@ declare global {
// The pop-out pet overlay: a transparent always-on-top window hosting only
// the mascot. The main renderer drives it (open/close/drag + state push);
// the overlay sends control messages back (pop-in, composer submit).
- petOverlay: {
+ petOverlay?: {
open: (request: PetOverlayOpenRequest) => Promise<{ ok: boolean; bounds?: PetOverlayBounds }>
close: () => Promise<{ ok: boolean }>
setBounds: (bounds: PetOverlayBounds) => void
@@ -167,7 +167,7 @@ declare global {
// Repo-first discovery: scan bounded roots for git repos (depth-capped).
scanRepos: (roots: string[], options?: { maxDepth?: number }) => Promise<{ root: string; label: string }[]>
}
- terminal: {
+ terminal?: {
dispose: (id: string) => Promise
onData: (id: string, callback: (payload: string) => void) => () => void
onExit: (id: string, callback: (payload: HermesTerminalExit) => void) => () => void
@@ -198,18 +198,18 @@ declare global {
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
getVersion: () => Promise
getRemoteDisplayReason?: () => Promise
- updates: {
+ updates?: {
check: () => Promise
apply: (opts?: DesktopUpdateApplyOptions) => Promise
getBranch: () => Promise<{ branch: string }>
setBranch: (name: string) => Promise<{ branch: string }>
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
}
- uninstall: {
+ uninstall?: {
summary: () => Promise
run: (mode: DesktopUninstallMode) => Promise
}
- themes: {
+ themes?: {
// Download a VS Code Marketplace extension and return the raw color
// theme files it contributes. The renderer converts + persists them.
fetchMarketplace: (id: string) => Promise
diff --git a/apps/desktop/src/vite-env.d.ts b/apps/desktop/src/vite-env.d.ts
index 11f02fe2a00..603f6d8bec7 100644
--- a/apps/desktop/src/vite-env.d.ts
+++ b/apps/desktop/src/vite-env.d.ts
@@ -1 +1,5 @@
///
+
+// Injected by vite.config.ts `define` — the renderer package version, used by
+// the web bridge's getVersion (no Electron main process to ask on web).
+declare const __HERMES_RENDERER_VERSION__: string
diff --git a/apps/desktop/src/web-bridge/index.ts b/apps/desktop/src/web-bridge/index.ts
index 1d18c0e7867..f59f65121c6 100644
--- a/apps/desktop/src/web-bridge/index.ts
+++ b/apps/desktop/src/web-bridge/index.ts
@@ -43,14 +43,13 @@ export const WEB_STUBBED_SURFACE: Record = {
fetchLinkTitle: 'needs a cross-origin fetch proxy; PrettyLink falls back to the URL',
getRecentLogs: 'main-process log buffer does not exist on web',
normalizePreviewTarget: 'main-process path/URL normalization; preview falls back',
- notify: 'delivered via the Notification API when permitted',
+ notify: 'delivered via the Notification API (permission requested at first use)',
oauthLoginConnectionConfig: 'sign-in happens on the gateway login page itself',
oauthLogoutConnectionConfig: 'sign-out happens on the gateway login page itself',
onBackendExit: 'no child process to observe',
onBootProgress: 'no main-process boot pipeline; snapshot comes from getBootProgress',
onBootstrapEvent: 'no first-launch bootstrap in a browser',
onPreviewFileChanged: 'no file watcher on web (Phase 2 candidate)',
- petOverlay: 'no OS overlay windows in a browser',
probeConnectionConfig: 'connection is fixed to the serving origin',
profile: 'a hosted instance serves one profile',
repairBootstrap: 'no first-launch bootstrap in a browser',
@@ -61,11 +60,7 @@ export const WEB_STUBBED_SURFACE: Record = {
selectPaths: 'native file picker; gateway-fs picker is Phase 2',
settings: 'default project dir is a local-machine concept',
stopPreviewFileWatch: 'no file watcher on web (Phase 2 candidate)',
- terminal: 'PTY-over-WebSocket lands in Phase 2 (/api/pty)',
testConnectionConfig: 'connection is fixed to the serving origin',
- themes: 'marketplace fetch needs a proxy endpoint; deferred (RFC D6)',
- uninstall: 'nothing installed locally',
- updates: 'the instance updates server-side',
watchPreviewFile: 'no file watcher on web (Phase 2 candidate)'
}
@@ -86,13 +81,18 @@ export const WEB_OMITTED_SURFACE: Record = {
onOpenUpdatesRequested: 'no app menu to emit it',
onPowerResume: "browsers have no resume signal; the boot path's online/visibilitychange listeners cover wake",
onWindowStateChanged: 'no native window chrome to report',
+ petOverlay: 'no OS overlay windows in a browser; the overlay app never mounts (?win=overlay is Electron-launched)',
renamePath: 'no gateway rename endpoint yet; project-tree rename hides',
revealPath: 'no OS file manager to reveal into',
setNativeTheme: 'no native window chrome to theme',
setPreviewShortcutActive: 'no global shortcut registration',
setTitleBarTheme: 'no native title bar',
setTranslucency: 'no compositor-backed window translucency',
+ terminal: 'PTY-over-WebSocket lands in Phase 2 (/api/pty); absence renders terminal tabs as closed',
+ themes: 'marketplace fetch needs a proxy endpoint; deferred (RFC D6); absence empties the theme search',
trashPath: 'no OS trash; destructive delete needs its own web decision',
+ uninstall: 'nothing installed locally; UninstallSection self-hides without the bridge',
+ updates: 'the instance updates server-side; About hides the update controls on web',
zoom: 'the browser owns page zoom (Ctrl +/-)'
}
@@ -290,7 +290,9 @@ export function createWebBridge(): HermesDesktopBridge {
remoteUrl: `${window.location.origin}${basePath()}`
}),
getVersion: async () => ({
- appVersion: 'web',
+ // Vite-define'd renderer package version; guarded so a non-vite harness
+ // (node --test, plain vitest transform) doesn't throw on the bare const.
+ appVersion: typeof __HERMES_RENDERER_VERSION__ === 'string' ? __HERMES_RENDERER_VERSION__ : 'web',
electronVersion: 'web',
hermesRoot: '',
nodeVersion: 'web',
@@ -455,10 +457,20 @@ export function createWebBridge(): HermesDesktopBridge {
set: async () => ({ profile: null })
},
notify: async payload => {
- if (!('Notification' in window) || Notification.permission !== 'granted') {
+ if (!('Notification' in window) || Notification.permission === 'denied') {
return false
}
+ // Point-of-use permission: the first real notification asks. 'default'
+ // (never asked) resolves here; a denial simply reports undelivered.
+ if (Notification.permission !== 'granted') {
+ const permission = await Notification.requestPermission()
+
+ if (permission !== 'granted') {
+ return false
+ }
+ }
+
new Notification(payload.title ?? 'Hermes', { body: payload.body, silent: payload.silent })
return true
@@ -476,60 +488,13 @@ export function createWebBridge(): HermesDesktopBridge {
},
revealLogs: async () => ({ error: 'Not available on web.', ok: false, path: '' }),
getRecentLogs: async () => ({ lines: [], path: '' }),
- petOverlay: {
- close: async () => ({ ok: false }),
- control: () => {},
- onControl: unsubscribe,
- onState: unsubscribe,
- open: async () => ({ ok: false }),
- pushState: () => {},
- setBounds: () => {},
- setFocusable: () => {},
- setIgnoreMouse: () => {}
- },
- terminal: {
- dispose: async () => false,
- onData: unsubscribe,
- onExit: unsubscribe,
- resize: async () => false,
- start: async () => {
- throw new Error('The embedded terminal is not available on web yet.')
- },
- write: async () => false
- },
onPreviewFileChanged: unsubscribe,
onBackendExit: unsubscribe,
onBootProgress: unsubscribe,
onBootstrapEvent: unsubscribe,
resetBootstrap: async () => ({ ok: false }),
repairBootstrap: async () => ({ ok: false }),
- cancelBootstrap: async () => ({ cancelled: false, ok: false }),
- updates: {
- apply: async () => ({ error: 'Updates are managed server-side on web.', ok: false }),
- check: async () => ({ reason: 'web', supported: false }),
- getBranch: async () => ({ branch: '' }),
- onProgress: unsubscribe,
- setBranch: async name => ({ branch: name })
- },
- uninstall: {
- run: async () => ({ error: 'Not available on web.', ok: false }),
- summary: async () => ({
- agent_installed: false,
- gui_installed: false,
- hermes_home: '',
- packaged_app_paths: [],
- platform: 'web',
- source_built_artifacts: [],
- userdata_dir: '',
- userdata_exists: false
- })
- },
- themes: {
- fetchMarketplace: async () => {
- throw new Error('Theme marketplace is not available on web yet.')
- },
- searchMarketplace: async () => []
- }
+ cancelBootstrap: async () => ({ cancelled: false, ok: false })
}
return bridge
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index 2a3e79acc88..4647eae0dbd 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -27,6 +27,14 @@ const fsAllow = [
export default defineConfig({
base: './',
+ // The renderer's own package version, for surfaces with no Electron main
+ // process to ask (the web bridge's getVersion). Electron ignores this and
+ // reports the running app's version over IPC.
+ define: {
+ __HERMES_RENDERER_VERSION__: JSON.stringify(
+ JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf8')).version ?? ''
+ )
+ },
plugins: [react(), tailwindcss()],
css: {
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index ecd47ab2943..e9bf8928ce4 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -92,7 +92,7 @@ try:
WebSocket, WebSocketDisconnect,
)
from fastapi.middleware.cors import CORSMiddleware
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.concurrency import run_in_threadpool
@@ -108,7 +108,7 @@ except ImportError:
WebSocket, WebSocketDisconnect,
)
from fastapi.middleware.cors import CORSMiddleware
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.concurrency import run_in_threadpool
@@ -119,6 +119,13 @@ except ImportError:
)
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
+
+# The desktop renderer built for the browser (apps/desktop `npm run build:web`).
+# Mounted at /app ONLY when HERMES_APP_DIST is explicitly set and the dist
+# exists — the Docker image sets it; local testing sets it per-session. No env
+# var means /app is never mounted, so a regular desktop build leaving a dist at
+# apps/desktop/dist can't silently turn this on.
+APP_DIST = Path(os.environ["HERMES_APP_DIST"]) if "HERMES_APP_DIST" in os.environ else None
_log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@@ -15699,6 +15706,99 @@ def _normalise_prefix(raw: Optional[str]) -> str:
return normalise_prefix(raw)
+def mount_app(application: FastAPI):
+ """Mount the desktop renderer's web build at ``/app``.
+
+ The same UI the Electron desktop ships, built browser-only
+ (``apps/desktop`` ``npm run build:web``) and served same-origin by this
+ gateway, so a Nous Portal user can open a hosted instance with no install
+ (docs/plans/2026-07-10-001). The renderer's web bridge reads the same
+ bootstrap globals as the dashboard SPA (session token / auth-required /
+ base path), so both auth modes work unchanged:
+
+ * loopback / token: ``__HERMES_SESSION_TOKEN__`` injected below.
+ * gated / OAuth: no token; the auth gate has already required a session
+ cookie before this handler runs (``/app`` is NOT in the gate's public
+ prefixes), and the bridge mints WS tickets over that cookie.
+
+ The desktop bundle is built with vite ``base: './'`` (relative asset
+ URLs), so under ``/app/`` the browser requests ``/app/assets/...`` — no
+ absolute-URL rewriting needed. Skipped entirely when headless, when
+ ``HERMES_APP_DIST`` is unset (``APP_DIST is None`` — the default for every
+ install that isn't the Docker image), or when the dist was never built:
+ regular installs never see these routes.
+ """
+ _headless = os.environ.get("HERMES_SERVE_HEADLESS") == "1"
+ if _headless or APP_DIST is None:
+ return
+ _index_path = APP_DIST / "index.html"
+ if not _index_path.exists():
+ # Env set but no dist: the operator asked for /app (e.g. the Docker
+ # image sets HERMES_APP_DIST unconditionally) but the build isn't
+ # there. Warn loudly instead of silently not mounting — otherwise
+ # a broken image build surfaces as an unexplained 404 on /app.
+ _log.warning(
+ "HERMES_APP_DIST is set but no dist found at %s — /app will not "
+ "be served. Build it with: cd apps/desktop && npm run build:web",
+ APP_DIST,
+ )
+ return
+
+ def _serve_app_index() -> HTMLResponse:
+ html = _index_path.read_text(encoding="utf-8")
+ gated = bool(getattr(app.state, "auth_required", False))
+ gated_js = "true" if gated else "false"
+ if gated:
+ bootstrap_script = (
+ f""
+ )
+ else:
+ bootstrap_script = (
+ f'"
+ )
+ html = html.replace("", f"{bootstrap_script}", 1)
+ return HTMLResponse(
+ html,
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
+ )
+
+ application.mount(
+ "/app/assets", StaticFiles(directory=APP_DIST / "assets"), name="app-assets"
+ )
+
+ @application.get("/app")
+ async def serve_app_bare():
+ # The bundle uses relative asset URLs (vite base './'), which resolve
+ # against the document URL: at /app they'd hit the dashboard's
+ # /assets/. Canonicalize to /app/ so they resolve to /app/assets/.
+ return RedirectResponse(url="/app/", status_code=307)
+
+ @application.get("/app/")
+ async def serve_app_root():
+ return _serve_app_index()
+
+ @application.get("/app/{full_path:path}")
+ async def serve_app(full_path: str):
+ file_path = APP_DIST / full_path
+ # Same traversal guard as the dashboard SPA mount below.
+ if (
+ full_path
+ and file_path.resolve().is_relative_to(APP_DIST.resolve())
+ and file_path.exists()
+ and file_path.is_file()
+ ):
+ return FileResponse(file_path)
+ # HashRouter: every client route lives after '#', so any non-file path
+ # is just the shell.
+ return _serve_app_index()
+
+
def mount_spa(application: FastAPI):
"""Mount the built SPA. Falls back to index.html for client-side routing.
@@ -16859,6 +16959,7 @@ _mount_plugin_api_routes()
from hermes_cli.dashboard_auth.routes import router as _dashboard_auth_router # noqa: E402
app.include_router(_dashboard_auth_router)
+mount_app(app)
mount_spa(app)