From ba44de06da10e90f6fcb673c7ea78b2776a7c5f1 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 9 Jun 2026 13:19:14 -0500 Subject: [PATCH] fix(install): self-heal a stuck Electron download (salvage of #42894) (#42998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- hermes_cli/main.py | 19 ++++ scripts/install.ps1 | 18 ++++ scripts/install.sh | 140 +++++++++++++++++++++++++-- tests/hermes_cli/test_gui_command.py | 36 ++++++- website/docs/user-guide/desktop.md | 20 ++++ 5 files changed, 222 insertions(+), 11 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 334c603e856..c849f5e975f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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= hermes desktop --force-build") sys.exit(build_result.returncode or 1) packaged_executable = _desktop_packaged_executable(desktop_dir) if not source_mode: diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ab116b6699d..71da2058ac9 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index 88e12399566..ce34ab2aa2c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 </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 </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= \\" + 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 diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index bf77e7970af..be84a30ee85 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -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 diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 9c095c5e099..5f132793f21 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -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 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`: