feat(desktop,cli,docker): serve the desktop renderer at /app on the gateway

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.
This commit is contained in:
emozilla 2026-07-11 01:14:35 -04:00
parent a014dec94a
commit 9f61698369
10 changed files with 277 additions and 144 deletions

View file

@ -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/

View file

@ -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

View file

@ -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",

View file

@ -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() {
</div>
<div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title={a.updates} />
{hasUpdater && (
<>
<SectionHeading icon={RefreshCw} title={a.updates} />
<div
className={cn(
'rounded-xl border px-4 py-3 text-sm',
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="textStrong"
<div
className={cn(
'rounded-xl border px-4 py-3 text-sm',
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
</Button>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div>
{behind > 0 && supported && !applying && (
<>
<Button onClick={() => startActiveUpdate()} size="sm">
{a.updateNow}
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="textStrong"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
</Button>
<Button onClick={() => openUpdatesWindow()} size="sm" variant="textStrong">
{a.seeWhatsNew}
{behind > 0 && supported && !applying && (
<>
<Button onClick={() => startActiveUpdate()} size="sm">
{a.updateNow}
</Button>
<Button onClick={() => openUpdatesWindow()} size="sm" variant="textStrong">
{a.seeWhatsNew}
</Button>
</>
)}
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.releaseNotes}
</a>
</Button>
</>
)}
</div>
</div>
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.releaseNotes}
</a>
</Button>
</div>
</div>
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
</>
)}
<UninstallSection />
</div>

View file

@ -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: [

View file

@ -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<boolean>
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<DesktopVersionInfo>
getRemoteDisplayReason?: () => Promise<string | null>
updates: {
updates?: {
check: () => Promise<DesktopUpdateStatus>
apply: (opts?: DesktopUpdateApplyOptions) => Promise<DesktopUpdateApplyResult>
getBranch: () => Promise<{ branch: string }>
setBranch: (name: string) => Promise<{ branch: string }>
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
}
uninstall: {
uninstall?: {
summary: () => Promise<DesktopUninstallSummary>
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
}
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<DesktopMarketplaceThemeResult>

View file

@ -1 +1,5 @@
/// <reference types="vite/client" />
// 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

View file

@ -43,14 +43,13 @@ export const WEB_STUBBED_SURFACE: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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

View file

@ -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

View file

@ -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"<script>"
f'window.__HERMES_BASE_PATH__="";'
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
f"</script>"
)
else:
bootstrap_script = (
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
f'window.__HERMES_BASE_PATH__="";'
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
f"</script>"
)
html = html.replace("</head>", f"{bootstrap_script}</head>", 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)