{"repo":"NousResearch/hermes-agent","public_id":"39472","display_label":"Desktop HERMES_WEB_DIST leaking into dashboard frontend","summary":"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.","subsystem":"apps/desktop","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":3,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":41411},"resolution":{"state":"partially_resolved","by":"pr-41411,pr-69891"},"freshness":{"state":"verified","last_verified_at":"2026-07-23T05:31:23+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.19377777777777774},"fix_edges":[{"issue_number":39472,"pr_number":41411,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":39472,"pr_number":41729,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":39484,"pr_number":41729,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":41327,"pr_number":41411,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":52945,"pr_number":69891,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":39472,"title":"Desktop dashboard passes ASAR-internal HERMES_WEB_DIST, causing frontend 404 on macOS","url":null,"state":"closed"},{"kind":"issue","number":41327,"title":"Desktop dashboard serves HTTP 404 — dist/ packed into app.asar but never unpacked (asarUnpack missing dist/**)","url":null,"state":"closed"},{"kind":"issue","number":52945,"title":"Dashboard inherits HERMES_WEB_DIST from Desktop, loading wrong frontend / Dashboard 继承 Desktop 的 HERMES_WEB_DIST，加载错误前端","url":null,"state":"closed"},{"kind":"pr","number":39501,"title":"fix(dashboard): fall back when HERMES_WEB_DIST is invalid","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d928017742d5cf242998e1667c994a23df85d80e","number":0},{"kind":"commit","value":"bddc5fd0873424bbefa7fdb48c53ee8834366892","number":0},{"kind":"pr","value":"#39472","number":39472}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":39554,"title":"fix(desktop): omit invalid dashboard web dist env","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"53a2ac8f2dba4b8fa647a8c5062b649d45b05464","number":0},{"kind":"pr","value":"#41411","number":41411},{"kind":"pr","value":"#39472","number":39472},{"kind":"pr","value":"#41729","number":41729}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":41411,"title":"fix(desktop): unpack dist/ from asar so dashboard static files are servable","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/package.json"]},{"kind":"pr","number":41729,"title":"fix(desktop): fail loudly instead of blank-paging when the renderer bundle is missing","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/main.cjs","apps/desktop/package.json","apps/desktop/scripts/assert-dist-built.cjs","apps/desktop/scripts/assert-dist-built.test.cjs"],"fix_hunks":[{"file":"apps/desktop/electron/main.cjs","hunks":["@@ -1902,12 +1902,36 @@ function resolveWebDist() {\n   const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist')\n   if (directoryExists(unpackedDist)) return unpackedDist\n \n-  return path.join(APP_ROOT, 'dist')\n+  // Final fallback: APP_ROOT/dist. When packaged with asar:true this lives\n+  // INSIDE app.asar — not a servable filesystem directory — so the embedded\n+  // dashboard backend 404s on static routes (see #41327, #39472). The durable\n+  // fix is unpacking dist/ (PR #41411 adds dist/** to asarUnpack so the tier-2\n+  // unpackedDist above resolves). If we still land here while packaged, log it\n+  // so the cause isn't silent.\n+  const fallback = path.join(APP_ROOT, 'dist')\n+  if (IS_PACKAGED && /app\\.asar(?=$|[\\\\/])/.test(fallback) && !directoryExists(fallback)) {\n+    rememberLog(\n+      `[web-dist] dashboard frontend dir resolved to an asar-internal path that ` +\n+        `is not a real directory: ${fallback}. Static routes will 404. ` +\n+        `Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.`\n+    )\n+  }\n+  return fallback\n }\n \n function resolveRendererIndex() {\n   const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')]\n-  return candidates.find(fileExists) || candidates[0]\n+  const found = candidates.find(fileExists)\n+  if (found) return found\n+  // Nothing on disk. A packaged build with no renderer bundle blank-pages with\n+  // a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause\n+  // and the fix before Electron loads the missing file.\n+  rememberLog(\n+    `[renderer] index.html not found — the desktop app was packaged without a ` +\n+      `renderer bundle. Tried: ${candidates.join(', ')}. ` +\n+      `Rebuild with: hermes desktop --force-build`\n+  )\n+  return candidates[0]\n }\n \n function resolveHermesCwd() {"]},{"file":"apps/desktop/package.json","hunks":["@@ -18,7 +18,7 @@\n     \"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 .\",\n     \"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 .\",\n     \"start\": \"npm run build && electron .\",\n-    \"build\": \"node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build\",\n+    \"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\",\n     \"builder\": \"cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder\",\n     \"pack\": \"npm run build && npm run builder -- --dir\",\n     \"dist\": \"npm run build && npm run builder\","]},{"file":"apps/desktop/scripts/assert-dist-built.cjs","hunks":["@@ -0,0 +1,70 @@\n+\"use strict\"\n+\n+// Build-time guard: refuse to hand a half-built renderer to electron-builder.\n+//\n+// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.\n+// If the `build` step (tsc -b && vite build) fails but packaging proceeds\n+// anyway — a stale checkout that fails typecheck, an interrupted vite build,\n+// or npm not short-circuiting `&&` in some shells — electron-builder happily\n+// packages an app with an empty or missing `dist/`. The result launches but\n+// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why.\n+//\n+// This runs at the tail of `build`, after vite build, so any packaging path\n+// inherits it. It fails loud and early instead of shipping a broken bundle.\n+// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).\n+\n+const fs = require(\"fs\")\n+const path = require(\"path\")\n+\n+// Pure check — returns { ok: true } or { ok: false, error: \"...\" }.\n+// Kept side-effect-free so it can be unit tested without spawning a process.\n+function checkDistBuilt(distDir) {\n+  if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) {\n+    return { ok: false, error: `no dist directory at ${distDir}` }\n+  }\n+\n+  const indexHtml = path.join(distDir, \"index.html\")\n+  if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) {\n+    return { ok: false, error: `dist/index.html is missing at ${indexHtml}` }\n+  }\n+  if (fs.statSync(indexHtml).size === 0) {\n+    return { ok: false, error: `dist/index.html is empty at ${indexHtml}` }\n+  }\n+\n+  // index.html alone isn't enough — vite emits hashed JS into dist/assets.\n+  // An index.html with no script bundle still blank-pages.\n+  const assetsDir = path.join(distDir, \"assets\")\n+  const hasAssets =\n+    fs.existsSync(assetsDir) &&\n+    fs.statSync(assetsDir).isDirectory() &&\n+    fs.readdirSync(assetsDir).some(name => name.endsWith(\".js\"))\n+  if (!hasAssets) {\n+    return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` }\n+  }\n+\n+  return { ok: true }\n+}\n+\n+function main() {\n+  const desktopRoot = path.resolve(__dirname, \"..\")\n+  const distDir = path.join(desktopRoot, \"dist\")\n+  const result = checkDistBuilt(distDir)\n+\n+  if (!result.ok) {\n+    console.error(`\\n✗ assert-dist-built: ${result.error}`)\n+    console.error(\"  The renderer bundle is missing or incomplete, so packaging\")\n+    console.error(\"  would produce an app that launches to a blank page.\")\n+    console.error(\"  Re-run the build and check the tsc/vite output above for the\")\n+    console.error(\"  real failure, then package again:\")\n+    console.error(`    cd ${desktopRoot} && npm run build\\n`)\n+    process.exit(1)\n+  }\n+\n+  console.log(\"✓ assert-dist-built: dist/index.html + assets present\")\n+}\n+\n+if (require.main === module) {\n+  main()\n+}\n+\n+module.exports = { checkDistBuilt }"]},{"file":"apps/desktop/scripts/assert-dist-built.test.cjs","hunks":["@@ -0,0 +1,84 @@\n+const assert = require('node:assert/strict')\n+const fs = require('node:fs')\n+const os = require('node:os')\n+const path = require('node:path')\n+const test = require('node:test')\n+\n+const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs')\n+\n+function makeDist(extra) {\n+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))\n+  const distDir = path.join(tempRoot, 'dist')\n+  fs.mkdirSync(distDir, { recursive: true })\n+  if (extra) extra(distDir)\n+  return { tempRoot, distDir }\n+}\n+\n+test('checkDistBuilt passes when index.html + an assets JS bundle exist', () => {\n+  const { tempRoot, distDir } = makeDist(d => {\n+    fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html><div id=root></div>', 'utf8')\n+    fs.mkdirSync(path.join(d, 'assets'))\n+    fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')\n+  })\n+  try {\n+    assert.deepEqual(checkDistBuilt(distDir), { ok: true })\n+  } finally {\n+    fs.rmSync(tempRoot, { recursive: true, force: true })\n+  }\n+})\n+\n+test('checkDistBuilt fails when the dist directory is absent', () => {\n+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))\n+  try {\n+    const result = checkDistBuilt(path.join(tempRoot, 'dist'))\n+    assert.equal(result.ok, false)\n+    assert.match(result.error, /no dist directory/)\n+  } finally {\n+    fs.rmSync(tempRoot, { recursive: true, force: true })\n+  }\n+})\n+\n+test('checkDistBuilt fails when index.html is missing', () => {\n+  const { tempRoot, distDir } = makeDist(d => {\n+    fs.mkdirSync(path.join(d, 'assets'))\n+    fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')\n+  })\n+  try {\n+    const result = checkDistBuilt(distDir)\n+    assert.equal(result.ok, false)\n+    assert.match(result.error, /index\\.html is missing/)\n+  } finally {\n+    fs.rmSync(tempRoot, { recursive: true, force: true })\n+  }\n+})\n+\n+test('checkDistBuilt fails when index.html is empty', () => {\n+  const { tempRoot, distDir } = makeDist(d => {\n+    fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8')\n+    fs.mkdirSync(path.join(d, 'assets'))\n+    fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')\n+  })\n+  try {\n+    const result = checkDistBuilt(distDir)\n+    assert.equal(result.ok, false)\n+    assert.match(result.error, /index\\.html is empty/)\n+  } finally {\n+    fs.rmSync(tempRoot, { recursive: true, force: true })\n+  }\n+})\n+\n+test('checkDistBuilt fails when assets/ has no JS bundle', () => {\n+  const { tempRoot, distDir } = makeDist(d => {\n+    fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')\n+    fs.mkdirSync(path.join(d, 'assets'))\n+    // CSS only, no JS — still a blank page at runtime.\n+    fs.writeFileSync(path.join(d, 'assets', 'index-abc123.css'), 'body{}', 'utf8')\n+  })\n+  try {\n+    const result = checkDistBuilt(distDir)\n+    assert.equal(result.ok, false)\n+    assert.match(result.error, /no built JS bundle/)\n+  } finally {\n+    fs.rmSync(tempRoot, { recursive: true, force: true })\n+  }\n+})\n"]}]},{"kind":"pr","number":52947,"title":"fix(cli): unset HERMES_WEB_DIST in cmd_dashboard to prevent desktop frontend leak / 防止桌面版环境变量泄漏到 Web Dashboard","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f0f8c84d1b7cbc739b4e05904d004f3ea3322945","number":0},{"kind":"commit","value":"8922169c9179bf307b2f677dc7a73d55de3bdb93","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":52948,"title":"fix(dashboard): drop desktop-inherited HERMES_WEB_DIST in standalone dashboard","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#69891","number":69891},{"kind":"pr","value":"#67402","number":67402}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":69891,"title":"fix(dashboard): isolate Desktop-inherited env from standalone launch (supersedes #52948, #67402)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","tests/hermes_cli/test_dashboard_web_dist_validation.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}