Desktop HERMES_WEB_DIST leaking into dashboard frontend
NousResearch/hermes-agent · cluster 39472 · apps/desktop
Data as of
Nothing open on GitHub. Fix merged in #41411. Check it covers your case.
The short answer
- Known?
- Yes. 10 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #41411. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #41411, #69891. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes all concern the same root problem: the desktop app sets HERMES_WEB_DIST to an ASAR-internal path that leaks into the standalone dashboard process, causing 404 errors or wrong frontend assets to be served. Proposed fixes address env isolation, ASAR unpacking, and fallback behavior.
Open issues0
Open PRs0
Fixes on record
-
#41411 fix(desktop): unpack dist/ from asar so dashboard static files are servable
files:
apps/desktop/package.json -
#41729 fix(desktop): fail loudly instead of blank-paging when the renderer bundle is missing
files:
apps/desktop/electron/main.cjs,apps/desktop/package.json,apps/desktop/scripts/assert-dist-built.cjs,apps/desktop/scripts/assert-dist-built.test.cjsShow the diff
-
apps/desktop/electron/main.cjs@@ -1902,12 +1902,36 @@ function resolveWebDist() { const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist') if (directoryExists(unpackedDist)) return unpackedDist - return path.join(APP_ROOT, 'dist') + // Final fallback: APP_ROOT/dist. When packaged with asar:true this lives + // INSIDE app.asar — not a servable filesystem directory — so the embedded + // dashboard backend 404s on static routes (see #41327, #39472). The durable + // fix is unpacking dist/ (PR #41411 adds dist/** to asarUnpack so the tier-2 + // unpackedDist above resolves). If we still land here while packaged, log it + // so the cause isn't silent. + const fallback = path.join(APP_ROOT, 'dist') + if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) { + rememberLog( + `[web-dist] dashboard frontend dir resolved to an asar-internal path that ` + + `is not a real directory: ${fallback}. Static routes will 404. ` + + `Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.` + ) + } + return fallback } function resolveRendererIndex() { const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] - return candidates.find(fileExists) || candidates[0] + const found = candidates.find(fileExists) + if (found) return found + // Nothing on disk. A packaged build with no renderer bundle blank-pages with + // a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause + // and the fix before Electron loads the missing file. + rememberLog( + `[renderer] index.html not found — the desktop app was packaged without a ` + + `renderer bundle. Tried: ${candidates.join(', ')}. ` + + `Rebuild with: hermes desktop --force-build` + ) + return candidates[0] } function resolveHermesCwd() { -
apps/desktop/package.json@@ -18,7 +18,7 @@ "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build", + "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", -
apps/desktop/scripts/assert-dist-built.cjs@@ -0,0 +1,70 @@ +"use strict" + +// Build-time guard: refuse to hand a half-built renderer to electron-builder. +// +// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`. +// If the `build` step (tsc -b && vite build) fails but packaging proceeds +// anyway — a stale checkout that fails typecheck, an interrupted vite build, +// or npm not short-circuiting `&&` in some shells — electron-builder happily +// packages an app with an empty or missing `dist/`. The result launches but +// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why. +// +// This runs at the tail of `build`, after vite build, so any packaging path +// inherits it. It fails loud and early instead of shipping a broken bundle. +// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404). + +const fs = require("fs") +const path = require("path") + +// Pure check — returns { ok: true } or { ok: false, error: "..." }. +// Kept side-effect-free so it can be unit tested without spawning a process. +function checkDistBuilt(distDir) { + if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) { + return { ok: false, error: `no dist directory at ${distDir}` } + } + + const indexHtml = path.join(distDir, "index.html") + if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) { + return { ok: false, error: `dist/index.html is missing at ${indexHtml}` } + } + if (fs.statSync(indexHtml).size === 0) { + return { ok: false, error: `dist/index.html is empty at ${indexHtml}` } + } + + // index.html alone isn't enough — vite emits hashed JS into dist/assets. + // An index.html with no script bundle still blank-pages. + const assetsDir = path.join(distDir, "assets") + const hasAssets = + fs.existsSync(assetsDir) && + fs.statSync(assetsDir).isDirectory() && + fs.readdirSync(assetsDir).some(name => name.endsWith(".js")) + if (!hasAssets) { + return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` } + } + + return { ok: true } +} + +function main() { + const desktopRoot = path.resolve(__dirname, "..") + const distDir = path.join(desktopRoot, "dist") + const result = checkDistBuilt(distDir) + + if (!result.ok) { + console.error(`\n✗ assert-dist-built: ${result.error}`) + console.error(" The renderer bundle is missing or incomplete, so packaging") + console.error(" would produce an app that launches to a blank page.") + console.error(" Re-run the build and check the tsc/vite output above for the") + console.error(" real failure, then package again:") + console.error(` cd ${desktopRoot} && npm run build\n`) + process.exit(1) + } + + console.log("✓ assert-dist-built: dist/index.html + assets present") +} + +if (require.main === module) { + main() +} + +module.exports = { checkDistBuilt } -
apps/desktop/scripts/assert-dist-built.test.cjs@@ -0,0 +1,84 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') + +const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs') + +function makeDist(extra) { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-')) + const distDir = path.join(tempRoot, 'dist') + fs.mkdirSync(distDir, { recursive: true }) + if (extra) extra(distDir) + return { tempRoot, distDir } +} + +test('checkDistBuilt passes when index.html + an assets JS bundle exist', () => { + const { tempRoot, distDir } = makeDist(d => { + fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html><div id=root></div>', 'utf8') + fs.mkdirSync(path.join(d, 'assets')) + fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8') + }) + try { + assert.deepEqual(checkDistBuilt(distDir), { ok: true }) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('checkDistBuilt fails when the dist directory is absent', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-')) + try { + const result = checkDistBuilt(path.join(tempRoot, 'dist')) + assert.equal(result.ok, false) + assert.match(result.error, /no dist directory/) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('checkDistBuilt fails when index.html is missing', () => { + const { tempRoot, distDir } = makeDist(d => { + fs.mkdirSync(path.join(d, 'assets')) + fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8') + }) + try { + const result = checkDistBuilt(distDir) + assert.equal(result.ok, false) + assert.match(result.error, /index\.html is missing/) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('checkDistBuilt fails when index.html is empty', () => { + const { tempRoot, distDir } = makeDist(d => { + fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8') + fs.mkdirSync(path.join(d, 'assets')) + fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8') + }) + try { + const result = checkDistBuilt(distDir) + assert.equal(result.ok, false) + assert.match(result.error, /index\.html is empty/) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('checkDistBuilt fails when assets/ has no JS bundle', () => { + const { tempRoot, distDir } = makeDist(d => { + fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8') + fs.mkdirSync(path.join(d, 'assets')) + // CSS only, no JS — still a blank page at runtime. + fs.writeFileSync(path.join(d, 'assets', 'index-abc123.css'), 'body{}', 'utf8') + }) + try { + const result = checkDistBuilt(distDir) + assert.equal(result.ok, false) + assert.match(result.error, /no built JS bundle/) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +})
-
-
#69891 fix(dashboard): isolate Desktop-inherited env from standalone launch (supersedes #52948, #67402)
files:
hermes_cli/main.py,tests/hermes_cli/test_dashboard_web_dist_validation.py
4 PRs closed without merging
From the close comments: done elsewhere 2 · merged via another change 1 · superseded by another PR 1
-
#39501 fix(dashboard): fall back when HERMES_WEB_DIST is invalid · done elsewhere →
d928017,bddc5fd,#39472 -
#39554 fix(desktop): omit invalid dashboard web dist env · merged via another change →
53a2ac8,#41411,#39472,#41729 -
#52947 fix(cli): unset HERMES_WEB_DIST in cmd_dashboard to prevent desktop frontend leak / 防止桌面版环境变量泄漏到 Web Dashboard · done elsewhere →
f0f8c84,8922169 -
#52948 fix(dashboard): drop desktop-inherited HERMES_WEB_DIST in standalone dashboard · superseded by another PR →
#69891,#67402
Closed issues · 3
- Closed#39472Desktop dashboard passes ASAR-internal HERMES_WEB_DIST, causing frontend 404 on macOS
- Closed#41327Desktop dashboard serves HTTP 404 — dist/ packed into app.asar but never unpacked (asarUnpack missing dist/**)
- Closed#52945Dashboard inherits HERMES_WEB_DIST from Desktop, loading wrong frontend / Dashboard 继承 Desktop 的 HERMES_WEB_DIST,加载错误前端
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.19: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab