From 9f616983695d6ce6d0bb898945161d44c28c5b4c Mon Sep 17 00:00:00 2001 From: emozilla Date: Sat, 11 Jul 2026 01:14:35 -0400 Subject: [PATCH] feat(desktop,cli,docker): serve the desktop renderer at /app on the gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web bridge (previous commit) made the renderer boot in a browser; this makes an instance actually serve it, end to end, plus the Phase 1 UI gating. Web bridge + renderer: - petOverlay/terminal/updates/uninstall/themes become OPTIONAL bridge members, absent on web rather than stubbed inert — every caller already ?.-guards, so features hide through existing branches (typecheck-verified) - Gateway settings nav entry absent on web (connection IS the serving origin); About hides the Updates section behind a capability check on window.hermesDesktop?.updates (version + release notes stay) - notify requests Notification permission at point of first use - getVersion reports the renderer package version via a vite define Serving (web_server.py): - APP_DIST/mount_app: /app/assets static mount, /app 307-canonicalized to /app/ (relative asset URLs), index served with the same bootstrap-global injection contract as the dashboard SPA (token in loopback mode, cookie + ws-ticket in gated mode); same traversal guard, same auth-gate coverage - Mounts ONLY when HERMES_APP_DIST is explicitly set: no path fallback, so a regular desktop build leaving a dist at apps/desktop/dist can't silently enable /app. Env set but dist missing logs a WARNING (a Docker image with a broken frontend build should not surface as a mystery 404) Build + image: - apps/desktop build:web script: renderer-only build (typecheck + vite), no Electron packaging steps - Dockerfile: desktop manifest joins the cached npm-install layer (ELECTRON_SKIP_BINARY_DOWNLOAD=1 keeps the ~100MB binary out), build:web appended to the frontend-build layer, HERMES_APP_DIST set at runtime - .dockerignore: apps/desktop source enters the build context; node_modules/ dist/build/release re-excluded after the negation (last-match-wins would otherwise re-include them — a stale local dist would clobber the image-built one via COPY . ., and a Windows node_modules would clobber the image's Linux natives) Verified: build:web dist served at /app/ from a real gateway (token injected server-side, host:'web', gateway WS connected, zero console errors); mount contract curl-verified in both directions (no env var = /app falls through to the dashboard SPA even with a dist present on disk). Image build itself pending a docker-capable machine. No-op for every install that doesn't set HERMES_APP_DIST; zero behavior change for Electron users. --- .dockerignore | 22 ++- Dockerfile | 39 +++-- apps/desktop/package.json | 1 + .../src/app/settings/about-settings.tsx | 134 ++++++++++-------- apps/desktop/src/app/settings/index.tsx | 21 ++- apps/desktop/src/global.d.ts | 10 +- apps/desktop/src/vite-env.d.ts | 4 + apps/desktop/src/web-bridge/index.ts | 77 +++------- apps/desktop/vite.config.ts | 8 ++ hermes_cli/web_server.py | 105 +++++++++++++- 10 files changed, 277 insertions(+), 144 deletions(-) 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 : ''} -

-
-
- -
- +
+ {statusTone === 'available' ? ( + + ) : statusTone === 'error' ? null : ( + + )} +
+

{statusLine}

+

+ {a.lastChecked(relativeTime(status?.fetchedAt, a))} + {justChecked && !checking ? a.justNowSuffix : ''} +

+
+
- {behind > 0 && supported && !applying && ( - <> - - + + + )} + + - - )} +
+
- -
- - - + + + )} 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)