# NousResearch/hermes-agent · Desktop HERMES_WEB_DIST leaking into dashboard frontend

## Open now

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

## TL;DR

- 10 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 3 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #41411. Verify it addresses your case.
- **Recommended:** read PR #41411 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-41411,pr-69891`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #39472: Desktop dashboard passes ASAR-internal HERMES_WEB_DIST, causing frontend 404 on macOS
- #41327: Desktop dashboard serves HTTP 404 — dist/ packed into app.asar but never unpacked (asarUnpack missing dist/**)
- #52945: Dashboard inherits HERMES_WEB_DIST from Desktop, loading wrong frontend / Dashboard 继承 Desktop 的 HERMES_WEB_DIST，加载错误前端

## Prior & competing PRs

- **#39501: fix(dashboard): fall back when HERMES_WEB_DIST is invalid**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `d928017742d5cf242998e1667c994a23df85d80e`, `bddc5fd0873424bbefa7fdb48c53ee8834366892`, `#39472`
- **#41411: fix(desktop): unpack dist/ from asar so dashboard static files are servable**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/package.json`
- **#41729: fix(desktop): fail loudly instead of blank-paging when the renderer bundle is missing**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `apps/desktop/electron/main.cjs`

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

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

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

    ```diff
    @@ -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 })
    +  }
    +})
    ```
- **#52947: fix(cli): unset HERMES_WEB_DIST in cmd_dashboard to prevent desktop frontend leak / 防止桌面版环境变量泄漏到 Web Dashboard**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `f0f8c84d1b7cbc739b4e05904d004f3ea3322945`, `8922169c9179bf307b2f677dc7a73d55de3bdb93`
- **#69891: fix(dashboard): isolate Desktop-inherited env from standalone launch (supersedes #52948, #67402)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`, `tests/hermes_cli/test_dashboard_web_dist_validation.py`
- **#39554: fix(desktop): omit invalid dashboard web dist env**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `53a2ac8f2dba4b8fa647a8c5062b649d45b05464`, `#41411`, `#39472`, `#41729`
- **#52948: fix(dashboard): drop desktop-inherited HERMES_WEB_DIST in standalone dashboard**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#69891`, `#67402`

## Fix status

- Merged to main in PR #41411 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-23`.
- Subsystem: `apps/desktop`
