{"repo":"NousResearch/hermes-agent","public_id":"37544","display_label":"Corrupt Electron download cache breaks desktop build","summary":"A bug where a corrupted cached Electron download causes the desktop build to fail on Linux, along with fixes to handle and recover from that corruption.","subsystem":"apps/desktop","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":39032,"merged_at":"2026-06-04T14:17:34+00:00"},"resolution":{"state":"resolved","by":"pr-39032"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T14:17:34+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":37544,"pr_number":39032,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-04T14:17:34+00:00"}],"members":[{"kind":"issue","number":37544,"title":"[Bug]: `hermes desktop` build fails on Linux with `ENOENT: rename 'electron' -> 'Hermes'` when the cached Electron download is corrupt","url":null,"state":"closed"},{"kind":"pr","number":37545,"title":"fix(linux-desktop): fix Electron download cache corruption handling on build","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f583c6ebd5bd9a15cbb9c8a757882dd7afc8df19","number":0},{"kind":"commit","value":"fef04a197e24ba607cfd03f1c3d33858a4b2e5c9","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":39032,"title":"fix(desktop): recover from corrupt cached Electron download on build","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/package.json","apps/desktop/scripts/before-pack.cjs","apps/desktop/scripts/before-pack.test.cjs","hermes_cli/main.py","tests/hermes_cli/test_gui_command.py"],"fix_hunks":[{"file":"apps/desktop/package.json","hunks":["@@ -146,6 +146,7 @@\n       \"package.json\"\n     ],\n     \"beforeBuild\": \"scripts/before-build.cjs\",\n+    \"beforePack\": \"scripts/before-pack.cjs\",\n     \"afterPack\": \"scripts/after-pack.cjs\",\n     \"extraResources\": [\n       {"]},{"file":"apps/desktop/scripts/before-pack.cjs","hunks":["@@ -0,0 +1,78 @@\n+'use strict'\n+\n+/**\n+ * before-pack.cjs — electron-builder beforePack hook.\n+ *\n+ * Removes any stale unpacked app directory (`appOutDir`) before\n+ * electron-builder stages the Electron binaries into it.\n+ *\n+ * WHY THIS EXISTS\n+ * ---------------\n+ * electron-builder's final packaging step copies the stock `electron`\n+ * binary into `release/<platform>-unpacked/` and then renames it to the\n+ * product name (`Hermes`). If a PREVIOUS `npm run pack` was interrupted\n+ * (Ctrl-C, OOM kill, crash, full disk) the unpacked directory is left in a\n+ * corrupted partial state: it keeps the already-renamed `LICENSE.electron.txt`\n+ * and the Chromium payload (.pak/.so/icudtl.dat/chrome-sandbox) but is MISSING\n+ * the `electron` binary itself.\n+ *\n+ * On the next run, electron-builder sees the destination directory already\n+ * populated, skips re-copying the binary it thinks is present, then tries to\n+ * rename a `electron` file that no longer exists. The build dies with:\n+ *\n+ *   ENOENT: no such file or directory, rename\n+ *   '.../release/linux-unpacked/electron' -> '.../release/linux-unpacked/Hermes'\n+ *\n+ * This is a hard failure with no obvious cause for the user — `hermes desktop`\n+ * just prints \"Desktop GUI build failed\" and the only fix is to manually\n+ * `rm -rf` the release directory, which a normal user has no way to know.\n+ *\n+ * The packaging step is not idempotent across an interrupted run, so we make\n+ * it idempotent ourselves: wipe the target unpacked directory up front so\n+ * electron-builder always stages into a clean tree. This is safe — the\n+ * directory is a pure build artifact that electron-builder fully recreates\n+ * on every pack; nothing else depends on its prior contents.\n+ *\n+ * Cross-platform: the same partial-state trap exists on macOS\n+ * (the mac-unpacked Hermes.app bundle) and Windows (win-unpacked), so we\n+ * clean whatever `appOutDir` electron-builder hands us regardless of platform.\n+ *\n+ * Best-effort: a cleanup failure must never mask the real build. We log and\n+ * resolve rather than throw — worst case electron-builder hits the original\n+ * ENOENT, which is no worse than not having this hook at all.\n+ *\n+ * electron-builder passes a context with:\n+ *   - appOutDir:            the unpacked app directory about to be staged\n+ *   - electronPlatformName: 'win32' | 'darwin' | 'linux'\n+ */\n+\n+const fs = require('node:fs')\n+\n+function cleanStaleAppOutDir(appOutDir) {\n+  if (!appOutDir || typeof appOutDir !== 'string') {\n+    return false\n+  }\n+  if (!fs.existsSync(appOutDir)) {\n+    return false\n+  }\n+  // Recursive + force so a half-written tree (read-only bits, partial files)\n+  // can't block the wipe. retry/maxRetries rides out transient EBUSY on\n+  // Windows where an AV/indexer may briefly hold a handle.\n+  fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })\n+  return true\n+}\n+\n+exports.cleanStaleAppOutDir = cleanStaleAppOutDir\n+\n+exports.default = async function beforePack(context) {\n+  const appOutDir = context && context.appOutDir\n+  try {\n+    if (cleanStaleAppOutDir(appOutDir)) {\n+      console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`)\n+    }\n+  } catch (err) {\n+    // Never fail the build over cleanup; surface why so a genuinely stuck\n+    // directory (permissions, mount) is still diagnosable.\n+    console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`)\n+  }\n+}"]},{"file":"apps/desktop/scripts/before-pack.test.cjs","hunks":["@@ -0,0 +1,53 @@\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 { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs')\n+\n+test('cleanStaleAppOutDir removes a populated unpacked directory', () => {\n+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))\n+  try {\n+    const appOutDir = path.join(tempRoot, 'linux-unpacked')\n+    fs.mkdirSync(appOutDir, { recursive: true })\n+    // Reproduce the corrupted partial state: license + payload present,\n+    // electron binary missing — exactly what trips the ENOENT rename.\n+    fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')\n+    fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')\n+    fs.mkdirSync(path.join(appOutDir, 'resources'), { recursive: true })\n+    fs.writeFileSync(path.join(appOutDir, 'resources', 'app.asar'), 'x', 'utf8')\n+\n+    const removed = cleanStaleAppOutDir(appOutDir)\n+\n+    assert.equal(removed, true)\n+    assert.equal(fs.existsSync(appOutDir), false)\n+  } finally {\n+    fs.rmSync(tempRoot, { recursive: true, force: true })\n+  }\n+})\n+\n+test('cleanStaleAppOutDir is a no-op when the directory is absent', () => {\n+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))\n+  try {\n+    const missing = path.join(tempRoot, 'does-not-exist')\n+    assert.equal(cleanStaleAppOutDir(missing), false)\n+  } finally {\n+    fs.rmSync(tempRoot, { recursive: true, force: true })\n+  }\n+})\n+\n+test('cleanStaleAppOutDir ignores empty or invalid input', () => {\n+  assert.equal(cleanStaleAppOutDir(''), false)\n+  assert.equal(cleanStaleAppOutDir(undefined), false)\n+  assert.equal(cleanStaleAppOutDir(null), false)\n+  assert.equal(cleanStaleAppOutDir(42), false)\n+})\n+\n+test('beforePack default export resolves even when cleanup throws', async () => {\n+  const { default: beforePack } = require('../scripts/before-pack.cjs')\n+  // A directory path that rmSync can't remove is simulated by passing a\n+  // context whose appOutDir is a file the hook will try (and be allowed) to\n+  // remove; the contract under test is that the hook never rejects.\n+  await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))\n+})"]},{"file":"hermes_cli/main.py","hunks":["@@ -7233,6 +7233,102 @@ def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:\n     return max(existing, key=lambda p: p.stat().st_mtime)\n \n \n+def _electron_download_cache_dirs() -> list[Path]:\n+    \"\"\"Return the per-user Electron download cache directories for this OS.\n+\n+    electron-builder's ``app-builder unpack-electron`` extracts the Electron\n+    distribution from a zip stored in this cache (NOT from node_modules), so a\n+    corrupt zip here — not a bad workspace install — is what poisons the build.\n+    Honors the ``electron_config_cache`` / ``ELECTRON_CACHE`` overrides that\n+    ``@electron/get`` respects, then falls back to the platform defaults.\n+    \"\"\"\n+    home = Path.home()\n+    candidates: list[Path] = []\n+    override = os.environ.get(\"electron_config_cache\") or os.environ.get(\"ELECTRON_CACHE\")\n+    if override:\n+        candidates.append(Path(override))\n+    if sys.platform == \"darwin\":\n+        candidates.append(home / \"Library\" / \"Caches\" / \"electron\")\n+    elif sys.platform == \"win32\":\n+        local = os.environ.get(\"LOCALAPPDATA\")\n+        if local:\n+            candidates.append(Path(local) / \"electron\" / \"Cache\")\n+        candidates.append(home / \"AppData\" / \"Local\" / \"electron\" / \"Cache\")\n+    else:\n+        xdg = os.environ.get(\"XDG_CACHE_HOME\")\n+        if xdg:\n+            candidates.append(Path(xdg) / \"electron\")\n+        candidates.append(home / \".cache\" / \"electron\")\n+\n+    seen: set[Path] = set()\n+    out: list[Path] = []\n+    for c in candidates:\n+        rc = c.expanduser()\n+        if rc not in seen:\n+            seen.add(rc)\n+            out.append(rc)\n+    return out\n+\n+\n+def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]:\n+    \"\"\"Clear the cached Electron download + half-written unpacked dir so the\n+    next ``pack`` re-downloads and re-stages from scratch.\n+\n+    Root cause of the ``ENOENT … rename '…/linux-unpacked/electron' ->\n+    '…/linux-unpacked/Hermes'`` desktop build failure: a corrupt zip in the\n+    per-user Electron download cache (a partial download resumed into the same\n+    file leaves prepended/concatenated junk, or an interrupted write truncates\n+    it). electron-builder's ``app-builder unpack-electron`` extracts the\n+    distribution from that cached zip (NOT from node_modules); a bad zip yields\n+    a partial tree MISSING the 193 MB ``electron`` binary, so the final rename\n+    dies. Re-running repeats the same broken extraction forever.\n+\n+    We deliberately do NOT try to detect corruption ourselves. stdlib\n+    ``zipfile`` silently tolerates the prepended/concatenated junk that is the\n+    most common corruption here — it reads from the end-of-central-directory\n+    backward, so ``testzip()`` returns clean on exactly the zips ``unzip -t``\n+    and ``@electron/get`` reject. Gating the purge on a self-rolled validator\n+    would therefore skip the real-world case and never self-heal. Instead, on a\n+    packaged-build failure we unconditionally remove the version's cached zips\n+    and the stale unpacked dir, then let the caller retry once: ``@electron/get``\n+    re-downloads with its own SHASUM verification (the real source of truth),\n+    and ``before-pack.cjs`` re-wipes the unpacked dir. If the failure was\n+    unrelated, a clean re-download is harmless and the retry fails the same way.\n+\n+    Best-effort: never raises. Returns the paths removed so the caller can log\n+    them and decide whether a retry is worthwhile (empty list ⇒ nothing to\n+    clear, so no point retrying).\n+    \"\"\"\n+    removed: list[Path] = []\n+\n+    for cache_dir in _electron_download_cache_dirs():\n+        if not cache_dir.is_dir():\n+            continue\n+        for zip_path in sorted(cache_dir.rglob(\"electron-*.zip\")):\n+            try:\n+                zip_path.unlink()\n+                removed.append(zip_path)\n+            except OSError:\n+                # Locked/permission-denied entry is out of our hands; let the\n+                # build report its own error rather than masking it.\n+                pass\n+\n+    # Drop the half-written unpacked dir too: an interrupted prior pack leaves\n+    # a partial tree that poisons the rename even after the zip is fixed.\n+    # (before-pack.cjs also handles this, but clearing it here makes the retry\n+    # robust even if the hook is somehow skipped.)\n+    release_dir = desktop_dir / \"release\"\n+    if release_dir.is_dir():\n+        for unpacked in release_dir.glob(\"*-unpacked\"):\n+            try:\n+                shutil.rmtree(unpacked, ignore_errors=True)\n+                removed.append(unpacked)\n+            except OSError:\n+                pass\n+\n+    return removed\n+\n+\n def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:\n     \"\"\"Make a locally-built (unsigned) macOS desktop app survive in-place self-update.\n ","@@ -7389,6 +7485,26 @@ def cmd_gui(args: argparse.Namespace):\n             print(f\"→ Building desktop {build_label}...\")\n             build_script = \"build\" if source_mode else \"pack\"\n             build_result = subprocess.run([npm, \"run\", build_script], cwd=desktop_dir, env=env, check=False)\n+            if build_result.returncode != 0 and not source_mode:\n+                # A corrupt cached Electron zip makes `pack` fail with an ENOENT\n+                # on the final `electron` -> `Hermes` rename: unpack-electron\n+                # extracted a partial tree (missing the 193 MB binary) from the\n+                # bad zip. We do NOT try to prove the zip is corrupt ourselves —\n+                # stdlib zipfile silently tolerates the prepended/concatenated\n+                # junk that is the most common corruption (a partial download\n+                # resumed into the same file), so a `testzip()` gate would pass\n+                # and never self-heal. Instead, on any packaged-build failure we\n+                # purge the version's cached zip + the half-written unpacked dir\n+                # and retry once: @electron/get re-downloads with its own SHASUM\n+                # verification, which is the real source of truth. If the\n+                # failure was something else, the clean re-download is harmless\n+                # and the retry fails the same way.\n+                purged = _purge_electron_build_cache(desktop_dir)\n+                if purged:\n+                    print(\"  ⚠ Desktop build failed; cleared cached Electron download and retrying once...\")\n+                    for p in purged:\n+                        print(f\"    - {p}\")\n+                    build_result = subprocess.run([npm, \"run\", build_script], cwd=desktop_dir, env=env, check=False)\n             if build_result.returncode != 0:\n                 print(\"✗ Desktop GUI build failed\")\n                 print(f\"  Run manually:  cd apps/desktop && npm run {build_script}\")"]},{"file":"tests/hermes_cli/test_gui_command.py","hunks":["@@ -411,3 +411,111 @@ def test_compute_desktop_content_hash_respects_gitignore(tmp_path, monkeypatch):\n     cli_main._DESKTOP_STAMP_SPEC = None\n     h3 = cli_main._compute_desktop_content_hash(root)\n     assert h1 != h3, \"changing a tracked file should change the hash\"\n+\n+\n+# ── Electron build-cache recovery tests ───────────────────────────────\n+\n+\n+def _write_zip(path: Path) -> None:\n+    import zipfile\n+\n+    path.parent.mkdir(parents=True, exist_ok=True)\n+    with zipfile.ZipFile(path, \"w\") as zf:\n+        zf.writestr(\"electron\", \"fake binary payload\")\n+\n+\n+def test_purge_electron_build_cache_clears_all_zips_and_unpacked_dir(tmp_path, monkeypatch):\n+    \"\"\"Purge is unconditional: it removes every electron-*.zip (regardless of\n+    whether stdlib zipfile thinks it's corrupt) plus the half-written unpacked\n+    dir, because @electron/get's own SHASUM check on re-download is the real\n+    validator — not a self-rolled one.\"\"\"\n+    cache = tmp_path / \"electron-cache\"\n+    # A \"clean\" zip and a prepended-junk zip — the latter is the real-world\n+    # corruption that zipfile.testzip() silently passes (it reads from the\n+    # end-of-central-directory backward), which is why we don't gate on it.\n+    clean = cache / \"electron-v40.9.3-linux-x64.zip\"\n+    prepended = cache / \"hashdir\" / \"electron-v40.9.3-linux-x64.zip\"\n+    _write_zip(clean)\n+    _write_zip(prepended)\n+    prepended.write_bytes(b\"\\x00\" * 4096 + prepended.read_bytes())\n+\n+    desktop_dir = tmp_path / \"apps\" / \"desktop\"\n+    unpacked = desktop_dir / \"release\" / \"linux-unpacked\"\n+    unpacked.mkdir(parents=True)\n+    (unpacked / \"LICENSE.electron.txt\").write_text(\"x\", encoding=\"utf-8\")\n+    (unpacked / \"resources.pak\").write_text(\"x\", encoding=\"utf-8\")\n+\n+    monkeypatch.setattr(cli_main, \"_electron_download_cache_dirs\", lambda: [cache])\n+\n+    removed = cli_main._purge_electron_build_cache(desktop_dir)\n+\n+    assert clean in removed\n+    assert prepended in removed\n+    assert unpacked in removed\n+    assert not clean.exists()\n+    assert not prepended.exists()\n+    assert not unpacked.exists()\n+\n+\n+def test_purge_electron_build_cache_empty_when_nothing_present(tmp_path, monkeypatch):\n+    \"\"\"No cached zips and no unpacked dir → nothing removed, so the caller\n+    knows a retry is pointless.\"\"\"\n+    cache = tmp_path / \"electron-cache\"\n+    cache.mkdir()\n+    desktop_dir = tmp_path / \"apps\" / \"desktop\"\n+    monkeypatch.setattr(cli_main, \"_electron_download_cache_dirs\", lambda: [cache])\n+\n+    assert cli_main._purge_electron_build_cache(desktop_dir) == []\n+\n+\n+def test_gui_retries_pack_once_after_purging_build_cache(tmp_path, monkeypatch):\n+    \"\"\"First pack fails, purge clears the cache, second pack succeeds, launch.\"\"\"\n+    root = _make_desktop_tree(tmp_path)\n+    monkeypatch.setattr(cli_main, \"PROJECT_ROOT\", root)\n+    packaged_exe = _make_packaged_executable(root, monkeypatch, platform=\"linux\")\n+\n+    install_ok = subprocess.CompletedProcess([\"npm\", \"ci\"], 0)\n+    pack_fail = subprocess.CompletedProcess([\"npm\", \"run\", \"pack\"], 1)\n+    pack_ok = subprocess.CompletedProcess([\"npm\", \"run\", \"pack\"], 0)\n+    launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)\n+\n+    with patch(\"hermes_cli.main.shutil.which\", return_value=\"/usr/bin/npm\"), \\\n+         patch(\"hermes_cli.main._run_npm_install_deterministic\", return_value=install_ok), \\\n+         patch(\"hermes_cli.main._desktop_macos_relaunchable_fixup\"), \\\n+         patch(\"hermes_cli.main._desktop_linux_sandbox_fixup\", return_value=True), \\\n+         patch(\"hermes_cli.main._write_desktop_build_stamp\"), \\\n+         patch(\"hermes_cli.main._purge_electron_build_cache\", return_value=[Path(\"/c/electron.zip\")]) as mock_purge, \\\n+         patch(\"hermes_cli.main.subprocess.run\", side_effect=[pack_fail, pack_ok, launch_ok]) as mock_run, \\\n+         pytest.raises(SystemExit) as exc:\n+        cli_main.cmd_gui(_ns())\n+\n+    assert exc.value.code == 0\n+    mock_purge.assert_called_once()\n+    # pack(fail) → purge → pack(ok) → launch = 3 subprocess.run calls\n+    assert mock_run.call_count == 3\n+    assert mock_run.call_args_list[0].args[0] == [\"/usr/bin/npm\", \"run\", \"pack\"]\n+    assert mock_run.call_args_list[1].args[0] == [\"/usr/bin/npm\", \"run\", \"pack\"]\n+    assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)]\n+\n+\n+def test_gui_does_not_retry_when_purge_finds_nothing(tmp_path, monkeypatch, capsys):\n+    \"\"\"If the purge clears nothing, there's no point retrying — fail fast.\"\"\"\n+    root = _make_desktop_tree(tmp_path)\n+    monkeypatch.setattr(cli_main, \"PROJECT_ROOT\", root)\n+    _make_packaged_executable(root, monkeypatch, platform=\"linux\")\n+\n+    install_ok = subprocess.CompletedProcess([\"npm\", \"ci\"], 0)\n+    pack_fail = subprocess.CompletedProcess([\"npm\", \"run\", \"pack\"], 1)\n+\n+    with patch(\"hermes_cli.main.shutil.which\", return_value=\"/usr/bin/npm\"), \\\n+         patch(\"hermes_cli.main._run_npm_install_deterministic\", return_value=install_ok), \\\n+         patch(\"hermes_cli.main._desktop_macos_relaunchable_fixup\"), \\\n+         patch(\"hermes_cli.main._purge_electron_build_cache\", return_value=[]) as mock_purge, \\\n+         patch(\"hermes_cli.main.subprocess.run\", side_effect=[pack_fail]) as mock_run, \\\n+         pytest.raises(SystemExit) as exc:\n+        cli_main.cmd_gui(_ns())\n+\n+    assert exc.value.code == 1\n+    mock_purge.assert_called_once()\n+    assert mock_run.call_count == 1\n+    assert \"Desktop GUI build failed\" in capsys.readouterr().out\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}