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