fix(install): self-heal a stuck Electron download (salvage of #42894) (#42998)

* fix(install): self-heal a stuck Electron download on the desktop build

The desktop build downloads Electron (~114MB) from GitHub. A corrupt cached
zip, or a blocked/throttled GitHub release host (the repeating "retrying" log),
hard-failed the install — and install.sh had no recovery at all while
install.ps1 / `hermes desktop` only purged the cache.

All three build paths now escalate on a failed `npm run pack`:
GitHub → purge corrupt electron-*.zip + stale *-unpacked and retry → one retry
via a public Electron mirror (npmmirror.com). @electron/get SHASUM-verifies the
download, and a user-pinned ELECTRON_MIRROR is always respected (never
overridden). Adds a bash clear_electron_build_cache()/_desktop_pack() to mirror
the existing PowerShell/Python helpers.

* test(install): cover the Electron mirror fallback

Verify `hermes desktop` falls back to a mirror when the cache purge finds
nothing, and that a user-pinned ELECTRON_MIRROR is respected (no extra attempt,
not overridden).

* docs(desktop): troubleshoot a stuck Electron download

Document the automatic cache-purge + mirror fallback, how to pin your own
ELECTRON_MIRROR, and how to clear a corrupt cached zip by hand.

* docs(install): correct the Electron mirror trust framing

The mirror-fallback comments and the desktop troubleshooting doc implied
`@electron/get`'s SHASUM check makes the npmmirror.com download safe against
tampering. It doesn't: the SHASUMS256.txt is fetched from the same mirror, so
the check guards against a corrupt/partial download, not a compromised mirror.

Reframe all four surfaces (install.sh, install.ps1, `hermes desktop`, and the
docs) to state the trust trade-off honestly — npmmirror.com is the de-facto
Electron community mirror, we only fall back to it after the canonical GitHub
download fails, and a user-pinned ELECTRON_MIRROR is never overridden. No
behavior change.

---------

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
This commit is contained in:
brooklyn! 2026-06-09 13:19:14 -05:00 committed by GitHub
parent 5750d058fa
commit ba44de06da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 222 additions and 11 deletions

View file

@ -5226,12 +5226,31 @@ def cmd_gui(args: argparse.Namespace):
# is still locked by a running instance; stop it before retry.
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0 and not source_mode and not env.get("ELECTRON_MIRROR"):
# Still failing and the user hasn't pinned a mirror: GitHub's
# Electron release host is likely blocked/throttled (the repeating
# "retrying" download log). Retry once via npmmirror.com — the
# de-facto Electron community mirror (Alibaba). @electron/get
# SHASUM-checks the download, but the SHASUMS come from the same
# mirror, so that guards against a corrupt/partial download, NOT
# a compromised mirror: reaching for it is an explicit trust
# trade-off we only make AFTER the canonical GitHub download has
# failed, and we never override a user-pinned ELECTRON_MIRROR.
print(" ⚠ Desktop build still failing; the Electron download from "
"GitHub looks blocked. Retrying once via a public mirror "
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
mirror_env = dict(env)
mirror_env["ELECTRON_MIRROR"] = "https://npmmirror.com/mirrors/electron/"
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False)
if build_result.returncode != 0:
print("✗ Desktop GUI build failed")
print(f" Run manually: cd apps/desktop && npm run {build_script}")
if sys.platform == "win32":
print(" If this says \"Access is denied\" on Hermes.exe, close any")
print(" running Hermes desktop window and retry.")
print(" If the log shows Electron download retries, rebuild via a mirror:")
print(" ELECTRON_MIRROR=<mirror-base-url> hermes desktop --force-build")
sys.exit(build_result.returncode or 1)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if not source_mode:

View file

@ -2248,6 +2248,24 @@ function Install-Desktop {
$code = $LASTEXITCODE
}
}
# Still failing and the user hasn't pinned their own mirror: GitHub's
# Electron release host is likely blocked/throttled (the repeating
# "retrying" log). Retry once via npmmirror.com — the de-facto Electron
# community mirror (Alibaba). @electron/get SHASUM-checks the download,
# but the SHASUMS come from the same mirror, so that guards against a
# corrupt/partial download, NOT a compromised mirror: an explicit trust
# trade-off we only make AFTER the canonical GitHub download has failed,
# and we never override a user-pinned ELECTRON_MIRROR.
if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) {
$prevMirror = $env:ELECTRON_MIRROR
$env:ELECTRON_MIRROR = "https://npmmirror.com/mirrors/electron/"
Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked."
Write-Warn "Retrying once via a public Electron mirror ($($env:ELECTRON_MIRROR)):"
Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
$code = $LASTEXITCODE
$env:ELECTRON_MIRROR = $prevMirror
}
$ErrorActionPreference = $prevEAP
if ($code -ne 0) {
$errText = Get-Content $buildLog -Raw -ErrorAction SilentlyContinue

View file

@ -2281,6 +2281,93 @@ postinstall_mode() {
fi
}
# Clear the cached Electron download + any half-written unpacked output so the
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
# the per-user Electron download cache - most often a partial/resumed download
# that leaves concatenated junk - makes electron-builder's `unpack-electron`
# extract a tree MISSING the electron binary, so the `electron`->`Hermes` rename
# dies with ENOENT and every re-run repeats the broken extraction forever. This
# is the bash sibling of install.ps1's Clear-ElectronBuildCache and the Python
# _purge_electron_build_cache() used by `hermes desktop`; install.sh was the only
# build path lacking it. Echoes the removed paths (one per line); best-effort.
clear_electron_build_cache() {
local desktop_dir="$1"
local removed=""
# Per-user Electron download cache dirs, honoring the overrides @electron/get
# respects, then the platform defaults (macOS: ~/Library/Caches/electron,
# Linux: $XDG_CACHE_HOME/electron or ~/.cache/electron).
local cache_dirs=()
[ -n "${electron_config_cache:-}" ] && cache_dirs+=("$electron_config_cache")
[ -n "${ELECTRON_CACHE:-}" ] && cache_dirs+=("$ELECTRON_CACHE")
if [ "$OS" = "macos" ]; then
cache_dirs+=("$HOME/Library/Caches/electron")
else
[ -n "${XDG_CACHE_HOME:-}" ] && cache_dirs+=("$XDG_CACHE_HOME/electron")
cache_dirs+=("$HOME/.cache/electron")
fi
local dir zip
for dir in "${cache_dirs[@]}"; do
[ -d "$dir" ] || continue
# Recurse: the bad copy may be the top-level zip OR a copy inside an
# @electron/get hash subdir.
while IFS= read -r zip; do
[ -n "$zip" ] || continue
if rm -f "$zip" 2>/dev/null; then
removed="$removed$zip
"
fi
done <<EOF
$(find "$dir" -type f -name 'electron-*.zip' 2>/dev/null)
EOF
done
# A half-written unpacked dir from an interrupted prior pack poisons the
# rename even after the zip is fixed (mac-arm64-unpacked / linux-unpacked).
local release_dir="$desktop_dir/release"
if [ -d "$release_dir" ]; then
local unpacked
while IFS= read -r unpacked; do
[ -n "$unpacked" ] || continue
if rm -rf "$unpacked" 2>/dev/null; then
removed="$removed$unpacked
"
fi
done <<EOF
$(find "$release_dir" -maxdepth 1 -type d -name '*-unpacked' 2>/dev/null)
EOF
fi
printf '%s' "$removed"
}
# Run the desktop pack in $1 (the apps/desktop dir). `npm run pack` = tsc +
# vite build + electron-builder --dir, producing an unpacked app for the
# current OS. Signing auto-discovery is disabled so electron-builder falls back
# to an ad-hoc signature instead of grabbing an unrelated Developer ID from the
# keychain (a real signed/notarized .dmg needs Apple credentials — a separate
# release concern). Optional $2 = an ELECTRON_MIRROR base URL for this attempt,
# used as a fallback when the default GitHub release download is blocked.
_desktop_pack() {
local desktop_dir="$1"
local mirror="${2:-}"
if [ -n "$mirror" ]; then
( cd "$desktop_dir" && ELECTRON_MIRROR="$mirror" CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack )
else
( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack )
fi
}
# Public Electron mirror used as a last-resort fallback when GitHub's release
# host is blocked/throttled (the repeating "retrying" symptom). npmmirror.com is
# the de-facto Electron community mirror (Alibaba). @electron/get SHASUM-checks
# the download, but the SHASUMS come from the same mirror — that guards against a
# corrupt/partial download, NOT a compromised mirror. Reaching for it is an
# explicit trust trade-off we only make AFTER the canonical GitHub download has
# failed, and we never override a user-pinned ELECTRON_MIRROR.
DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/"
# Build apps/desktop into a launchable native app. Mirrors install.ps1's
# Install-Desktop: a root-level npm install so the apps/* workspace resolves
# the desktop's own deps (Electron ~150MB), then `npm run pack`
@ -2338,18 +2425,53 @@ install_desktop() {
}
log_success "Desktop workspace dependencies installed"
# 2. Build. `npm run pack` = tsc + vite build + electron-builder --dir,
# producing an unpacked app for the current OS. We disable signing
# auto-discovery so electron-builder falls back to an ad-hoc signature
# instead of grabbing an unrelated Developer ID from the keychain; a
# real signed/notarized .dmg needs Apple credentials and is a separate
# release concern.
# 2. Build, with up to three escalating attempts so a transient/blocked
# Electron download self-heals instead of failing the whole install:
# a) plain `npm run pack` (downloads Electron from GitHub),
# b) on failure, purge a corrupt cached zip + stale unpacked dir and
# retry (matches install.ps1 / `hermes desktop`),
# c) on still-failing, fall back to a public Electron mirror — this is
# the GitHub-blocked/throttled case (the repeating "retrying" log).
log_info "Building desktop app (this takes 1-3 minutes)..."
( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack ) || {
local pack_ok=false
if _desktop_pack "$desktop_dir"; then
pack_ok=true
else
# (b) Corrupt cached Electron zip is the most common self-healable cause.
local purged
purged="$(clear_electron_build_cache "$desktop_dir")"
if [ -n "$purged" ]; then
log_warn "Desktop build failed; cleared cached Electron download and retrying once..."
if _desktop_pack "$desktop_dir"; then
pack_ok=true
fi
fi
fi
# (c) Still failing and the user hasn't pinned their own mirror: the GitHub
# release host is likely blocked/throttled. Retry once via a public
# Electron mirror (@electron/get still SHASUM-verifies the download).
if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then
log_warn "Desktop build still failing — the Electron download from GitHub looks blocked."
log_warn "Retrying once via a public Electron mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR)..."
log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
pack_ok=true
fi
fi
if [ "$pack_ok" = false ]; then
log_error "Desktop app build failed"
log_info "Run manually: cd $desktop_dir && npm run pack"
# If the log shows repeated "retrying" lines fetching the Electron zip,
# the binary download is blocked/throttled (firewall, proxy, region) and
# the mirror fallback above also couldn't reach a host. Try a mirror you
# trust and rebuild (@electron/get honors ELECTRON_MIRROR):
log_info "If the log shows Electron download retries, rebuild via a reachable mirror:"
log_info " ELECTRON_MIRROR=<mirror-base-url> \\"
log_info " bash -c 'cd \"$desktop_dir\" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack'"
log_info "Otherwise build manually: cd $desktop_dir && npm run pack"
return 1
}
fi
local app=""
if [ "$OS" = "linux" ]; then

View file

@ -498,11 +498,42 @@ def test_gui_retries_pack_once_after_purging_build_cache(tmp_path, monkeypatch):
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."""
def test_gui_falls_back_to_mirror_when_purge_finds_nothing(tmp_path, monkeypatch, capsys):
"""Purge clears nothing (not a cache problem) → fall back to an Electron
mirror once before failing, so a GitHub-blocked download self-heals."""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch, platform="linux")
monkeypatch.delenv("ELECTRON_MIRROR", raising=False)
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, 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()
# pack(fail) → purge(nothing) → pack via mirror(fail) = 2 subprocess.run calls
assert mock_run.call_count == 2
# The retry runs the same build but with ELECTRON_MIRROR injected.
assert "ELECTRON_MIRROR" not in (mock_run.call_args_list[0].kwargs.get("env") or {})
assert mock_run.call_args_list[1].kwargs["env"]["ELECTRON_MIRROR"]
assert "Desktop GUI build failed" in capsys.readouterr().out
def test_gui_does_not_override_user_electron_mirror(tmp_path, monkeypatch, capsys):
"""A user-pinned ELECTRON_MIRROR is respected: no extra mirror fallback
attempt (and we never swap in our default mirror)."""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch, platform="linux")
monkeypatch.setenv("ELECTRON_MIRROR", "https://mirror.example/electron/")
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
@ -518,6 +549,7 @@ def test_gui_does_not_retry_when_purge_finds_nothing(tmp_path, monkeypatch, caps
assert exc.value.code == 1
mock_purge.assert_called_once()
assert mock_run.call_count == 1
assert mock_run.call_args_list[0].kwargs["env"]["ELECTRON_MIRROR"] == "https://mirror.example/electron/"
assert "Desktop GUI build failed" in capsys.readouterr().out

View file

@ -233,6 +233,26 @@ rm -rf "$HOME/.hermes/hermes-agent/venv"
tccutil reset Microphone com.nousresearch.hermes
```
### "Build desktop app" stuck on Electron download
The build downloads the Electron runtime (~114&nbsp;MB) from `github.com/electron/electron/releases`. If the installer hangs on the **Build desktop app** step with the live output repeating `retrying attempt=…`, GitHub is being blocked or throttled on your network (firewall, proxy, or region).
The installer self-heals this automatically: on a failed build it (1) clears a corrupt cached Electron zip and retries, then (2) if it still fails and you haven't set `ELECTRON_MIRROR`, retries once more through `npmmirror.com`, the de-facto Electron community mirror. `@electron/get` SHASUM-checks the download, but the checksums come from the same mirror — that catches a corrupt or partial download, not a compromised mirror. If you'd rather not trust a third-party host, pin your own `ELECTRON_MIRROR` (below); the build never overrides one you've set.
To **choose your own mirror** (e.g. a corporate/trusted one), set `ELECTRON_MIRROR` before installing or rebuild manually — the build honors it and won't override it:
```bash
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ \
bash -c 'cd "$HOME/.hermes/hermes-agent/apps/desktop" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack'
```
To clear a corrupt cached zip by hand:
```bash
rm -f "$HOME/Library/Caches/electron"/electron-*.zip # macOS
rm -f "$HOME/.cache/electron"/electron-*.zip # Linux
```
## Building from source
If you want to hack on the app itself, install workspace deps from the repo root once, then run the dev server from `apps/desktop`: