* feat(providers): remove google-gemini-cli + google-antigravity OAuth providers
Google now actively bans accounts for third-party tools that piggyback on
Gemini CLI / Antigravity / Code Assist OAuth, and because abuse prevention
sits at a backend layer the ban can extend to the entire Google account
(Gmail/Drive), with a second violation being permanent.
Ref: https://github.com/google-gemini/gemini-cli/discussions/20632
Removes both OAuth inference providers entirely (modules, provider profiles,
auth/runtime/config/models wiring, the /gquota Code Assist quota command,
the antigravity-cli optional skill, desktop + docs surface in en + zh-Hans).
The API-key 'gemini' provider (GOOGLE_API_KEY/GEMINI_API_KEY against
generativelanguage.googleapis.com) is unaffected and stays fully supported.
* fix(skills): keep the antigravity-cli skill — only the OAuth provider is removed
The antigravity-cli optional skill orchestrates the external `agy` binary as
a coding-agent tool via the terminal tool — it does NOT wrap Hermes inference
through the banned google-antigravity OAuth provider, so it carries none of
the account-ban risk that motivated removing that provider. Restore the skill,
its docs page, the sidebar entry, and the optional-skills catalog row. The
google-antigravity / google-gemini-cli inference providers stay fully removed.
On a Linux source install the in-app updater ran the full backend update +
desktop rebuild successfully but never restarted the app — it hung forever on
the applying overlay with no close button. Two causes:
- applyUpdatesPosixInApp() only handled the macOS .app bundle swap;
runningAppBundle() is null off macOS, so Linux fell through to
{ ok: true, backendUpdated: true } without ever relaunching.
- The renderer store had no terminal state for that result shape, so
$updateApply stayed { applying: true } and the overlay's close button
(hidden while applying) never appeared.
Fix (new electron/update-relaunch.cjs, pure + unit-tested):
- Decide the Linux outcome from whether the *running* binary is the one we
just rebuilt (execPath under release/<plat>-unpacked, path-segment-aware so
linux-unpacked-evil can't masquerade) and whether its chrome-sandbox helper
is launchable (root:root + setuid, or an --no-sandbox / ELECTRON_DISABLE_SANDBOX
opt-out):
relaunch — detached watcher waits for this PID to exit (graceful, then
SIGKILL), self-deletes, and re-execs the rebuilt binary with the original
launch context (filtered args + HERMES_*/sandbox env + cwd) restored.
guiSkew — AppImage/.deb/.rpm/dev: backend updated but this GUI package was
NOT changed; surface an honest closeable 'reinstall the desktop app'
terminal state instead of lying that it loads next launch (#37541 skew).
manual — rebuilt binary but sandbox helper not launchable: keep the
working window, don't quit into a dead app.
- store/updates.ts lands a terminal, closeable state for EVERY resolved apply
outcome (handedOff / guiSkew / manualRestart / updated-not-relaunched / error)
so the hang is impossible regardless of platform or result.
- New DesktopUpdateStage values (update/rebuild/done/guiSkew) + GuiSkewView so
progress reads correctly and the skew state is closeable. i18n in all four
locales (en/ja/zh/zh-hant) in parity.
- electron/update-relaunch.test.cjs (16 tests) + store outcome tests.
Salvaged from #45205 onto current main. Linux quit dwell uses the shared
UPDATE_HANDOFF_DWELL_MS (2.5s) from #50448 for consistency. Four-locale i18n
parity, AUTHOR_MAP entry, and the test wiring added on top.
Closes#45205.
* fix(desktop): filter undefined entries in AttachmentList to prevent refText crash on session switch
When switching sessions, the attachments array can contain stale/undefined
entries from the previous session's state. Accessing attachment.refText on
an undefined entry throws TypeError, breaking session switching entirely.
Fix: add .filter(Boolean) before .map() to skip undefined/null entries.
Fixes#49614
* fix(desktop): update I18nConfigClient usage in attachment test
The i18n config API changed from getLocale/saveLocale to
getConfig/saveConfig. Update the test fixture to match.
The pop-out position is a bottom-right corner inset; the old clamp only floored
it and capped each inset by a flat constant, so dragging left/up (or restoring a
position saved on a larger/other monitor) could push the box's width/height past
the left/top edges and strand it off-screen — unrecoverable since the bad spot
persisted to localStorage.
Now the clamp bounds the WHOLE box (accounting for its measured width/height plus
an edge margin) on all four sides. Applied on drag (measured size), on load
(clamped in readPosition), and via a mount + window-resize reclamp so a shrunk
window or stale persisted value always pulls the box back into view.
Follow-up to #50238/#50381. The restart-loop is now SAFE (marker + launch
gate), but the trigger that lured users into relaunching mid-update remained:
on the in-app update hand-off the desktop window vanished almost immediately
(app.quit() 600ms after spawning the detached updater), before the updater's
own window appeared — a blank-screen gap that looks like a crash.
- Linger on the update overlay for UPDATE_HANDOFF_DWELL_MS (2.5s, was 600ms)
before quitting, on BOTH hand-off paths (in-app update + Windows bootstrap
recovery), so the message lands and bridges to the updater window.
- Strengthen the restart-stage copy and the overlay's applyingBody/applyingClose
to explicitly tell the user the window will reopen automatically and NOT to
reopen Hermes themselves while it updates. All four locales (en/ja/zh/zh-hant)
updated in parity.
Pure UX; does not touch the #50381 marker/gate mutual-exclusion safety net.
When a Windows user relaunches Hermes while an in-app update is still
running (the desktop vanished with no progress and looks crashed), the
fresh instance spawns its own dashboard backend. That backend re-locks
the venv shim, the updater's straggler cleanup (force_kill_other_hermes
-> taskkill /F /T /IM hermes.exe) kills it, the launch dies with the 45s
"backend didn't come up" timeout, and the user relaunches into the same
trap -- an infinite respawn/kill loop (#50238).
Root cause: no mutual exclusion between an applying update and a fresh
desktop spawning its own local backend.
Fix: the updater publishes a HERMES_HOME/.hermes-update-in-progress
marker (pid + start time) for the whole run via an RAII drop-guard that
removes it on every exit path (success, early return, panic). A
freshly-launched desktop checks the marker before spawning its local
backend and PARKS until the update finishes -- then brings the backend
up itself (it is the surviving instance; the updater's own relaunch hits
the single-instance lock and quits). A stale marker (dead pid or past a
20-minute ceiling) is pruned so a crashed updater can never strand
future launches. No rogue backend spawns mid-update, so
force_kill_other_hermes has nothing legitimate to kill.
Marker parse/staleness logic is extracted to update-marker.cjs and
unit-tested; the Rust guard has unit tests; the Rust-write <-> JS-read
contract is E2E-verified.
The port-announcement clock in waitForDashboardPort starts the instant the
backend process is spawned — before uvicorn binds its socket. On a cold
install the child first compiles and imports the whole hermes_cli.main ->
web_server -> FastAPI/uvicorn chain, and on Windows real-time AV scans every
freshly written .pyc. That pre-bind cost can exceed the old hardcoded 45s
deadline, so the desktop killed a healthy-but-still-starting backend and
respawned it, piling up orphaned processes (#50209).
Raise the default to 90s and make it overridable via
HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor so a bad
override can't reintroduce the loop. Warm starts still announce in well under
a second; both call sites inherit the new default with no change. Adds
backend-ready.test.cjs (wired into test:desktop:platforms).
The RPC-rename fallback swallowed all errors silently. Narrow it to log
the swallowed error via console.warn so a genuine session.title RPC
failure (which then surfaces a REST 404 for the runtime id) is
diagnosable instead of invisible. Behavior is unchanged: REST fallback
still runs for any session with a persisted row.
Verifies the active branched session renames via the session.title RPC
(not REST), and that REST is used for non-active rows, title clears, RPC
failures (socket mid-reconnect), and when no gateway is connected.
A freshly branched session (and any brand-new chat) lives only in the
gateway's in-memory _sessions map keyed by its runtime id — no row is
persisted to state.db until the first turn. The rename dialog hit REST
PATCH /api/sessions/{id}, which resolves against the stored sessions
table, so it 404'd with "Session not found" on these runtime-only rows.
Route the rename of the ACTIVE/selected session through the gateway's
session.title RPC (which resolves the live runtime session and persists
the row on demand), mirroring the /title slash command. Fall back to REST
for non-active rows, title clears, and when no gateway is connected.
Follow-up to the salvaged #47450 fix:
- Extract expandProviderDefaults() so the curated-default expansion rule
lives in one place (was duplicated between defaultVisibleKeys and
resolveVisibleKeys).
- Drop the redundant new Set() wrap in toggleModelVisibility (resolveVisibleKeys
already returns a fresh Set; effectiveVisibleKeys already relied on this).
- Document the intentional re-enable behavior (re-enabling one model of a
hidden-all provider restores only that model, not the curated defaults) and
tighten the toggleModelVisibility JSDoc.
- Add 7 hardening tests: re-enable-restores-only-that-model, full hide/re-enable
round-trip, empty-non-null stored, single toggle-off from null defaults,
zero-model provider, and direct resolveVisibleKeys null/empty assertions.
#43496 added a per-provider hide-all sentinel ('provider::') so emptying a provider in the Edit Models dialog stopped re-expanding its defaults. That fixed the single-provider case, but the dialog's toggle handler seeds its working set from effectiveVisibleKeys(), which strips ALL sentinels before returning. So persisting after any toggle silently dropped every OTHER provider's hide-all sentinel; those providers then looked 'never customized' and re-enabled all their models on the next render.
Split resolution into two functions:
- resolveVisibleKeys(): stored keys + curated default expansion, with hide-all sentinels PRESERVED — the canonical working set the toggle handler mutates and persists.
- effectiveVisibleKeys(): resolveVisibleKeys() then strips sentinels, for display only (unchanged contract).
Move the toggle set-computation into a pure, unit-tested toggleModelVisibility() that seeds from resolveVisibleKeys(), so sibling sentinels survive the persist. Add regression tests that drive the real toggle handler across multiple providers.
Follow-up to #43496; completes the fix for #43485 (cross-provider case).
Verify createLinkTitleWindow mutes audio (regression guard for #49505) and
keeps the hardened offscreen defaults, and register the new test file in the
desktop platforms test script.
Tier-2 link-title resolution loads the URL in an offscreen BrowserWindow to
read its <title> when curl can't. That window was never muted, so pages that
autoplay media (e.g. YouTube `watch` URLs) leaked ~2s of audio every time a
session containing such links was re-rendered. Move the window creation into a
dedicated helper that calls `webContents.setAudioMuted(true)` immediately after
construction, so the offscreen probe can never emit sound.
Fixes#49505
- Peel-off undock drops the floating composer under the cursor (centered
horizontally, preserving the vertical grab offset) instead of snapping to
the docked corner.
- Unify the / · @ · ? completion drawer and the attach (+) menu onto one
shared glassy panel primitive (composerPanelCard): smallest theme font,
hairline border, nous shadow; floats off the composer, inset from the left.
- Directive chips: Backspace removes the chip + its auto-inserted trailing
space atomically (no orphaned space), and a phantom trailing block left by
contenteditable no longer falsely expands the composer to two rows.
- Model picker: scroll area capped at max(150px, 30dvh); footer rows aligned
(matching icons, dropped a redundant margin).
- Composer focus shifts the border ~15% toward foreground (no fill change);
input is cursor-text; trimmed control icon/button sizes.
Gesture-driven: drag the docked composer up to peel it out, drag it back to
the bottom-center dock zone (radial glow ramps with proximity) to redock, and
double-click the grab area to toggle. Floating composer is compact, grows
upward as it wraps, and can be moved by its 5px transparent grab platform
(diagonal hatch on hover). Position + popped state persist; secondary windows
always start docked. rAF-coalesced drag, persisted only on release.
Remote displays (RDP/SSH/X11) silently disable GPU hardware acceleration with
only a console.log, leaving the user unaware that software rendering is
active. Expose the detected reason over IPC and surface a dismissible banner
in the renderer.
Behavior-preserving cleanups on the managed-node resolver:
- Hoist _candidate_node_command_names() out of the inner dir loop in
find_hermes_node_executable (computed once, not per directory).
- Drop redundant os.environ.copy() at the two with_hermes_node_path(
os.environ.copy()) sites \u2014 the helper already copies os.environ when
called with no argument (verified env-equivalent).
- Add reciprocal keep-in-sync comments between iter_hermes_node_dirs()
(hermes_constants.py) and hermesManagedNodePathEntries() (electron
main.cjs), which mirror the same platform-ordering rule across the
Python/Node boundary.
* fix(desktop): rename "Restart messaging" -> "Restart gateway"
The Command Center control restarts the whole messaging gateway, yet was
labelled "Restart messaging" while the status line above it reads "Messaging
gateway running/stopped". Rename the i18n key to match what it does, across
all 4 locales.
* feat(desktop): restart the gateway from Cmd+K, with statusbar spinner feedback
Add a shared runGatewayRestart() (store/system-actions.ts) and wire it to a
new Cmd+K "Restart gateway" action. While a restart is in flight the
statusbar "Gateway" item swaps its icon for the TUI glyph spinner and reads
"restarting…", returning to its real state on completion — driven by a
$gatewayRestarting atom, not a transient toast or the generic "Agents
running" counter. The helper owns its error handling so fire-and-forget
callers can't leak an unhandled rejection; only a failure toasts.
* fix(desktop): offer a Restart gateway action on messaging save/toggle toasts
The "setup saved" and "platform enabled/disabled" toasts told users their
change needs a gateway restart but left it a separate hunt. Attach a "Restart
gateway" action (the shared runGatewayRestart), and reword the copy to state
the pending consequence ("...takes effect after a gateway restart") now that
the button carries the verb. Updated all 4 locales.
* fix(desktop): make rendered logs selectable so they can be copied
The global body { user-select: none } left log surfaces unselectable. Opt them
back in via the existing data-selectable-text convention — at the shared
LogView primitive (boot-failure + bootstrap install overlays) plus Command
Center recent logs, toolset post-setup output, notification detail, and
subagent stream/file lines.
The global body { user-select: none } left log surfaces unselectable. Opt them
back in via the existing data-selectable-text convention — at the shared
LogView primitive (boot-failure + bootstrap install overlays) plus Command
Center recent logs, toolset post-setup output, notification detail, and
subagent stream/file lines.
The "setup saved" and "platform enabled/disabled" toasts told users their
change needs a gateway restart but left it a separate hunt. Attach a "Restart
gateway" action (the shared runGatewayRestart), and reword the copy to state
the pending consequence ("...takes effect after a gateway restart") now that
the button carries the verb. Updated all 4 locales.
Add a shared runGatewayRestart() (store/system-actions.ts) and wire it to a
new Cmd+K "Restart gateway" action. While a restart is in flight the
statusbar "Gateway" item swaps its icon for the TUI glyph spinner and reads
"restarting…", returning to its real state on completion — driven by a
$gatewayRestarting atom, not a transient toast or the generic "Agents
running" counter. The helper owns its error handling so fire-and-forget
callers can't leak an unhandled rejection; only a failure toasts.
The Command Center control restarts the whole messaging gateway, yet was
labelled "Restart messaging" while the status line above it reads "Messaging
gateway running/stopped". Rename the i18n key to match what it does, across
all 4 locales.
The desktop slash dispatcher dropped the `notice` field on `send` and
never handled `prefill` directives at all. `/goal <text>` returns
{type: send, notice: "⊙ Goal set …", message} from command.dispatch —
the desktop submitted the goal text as a plain prompt with no feedback,
so the goal looked like it did nothing. `/undo` returns a prefill
directive that fell through to "invalid response".
- types: add `notice?` to SendCommandDispatchResponse; add
PrefillCommandDispatchResponse to the union.
- parseCommandDispatch: keep `notice` on send, parse prefill.
- runExec dispatcher: render the notice as a system line before acting,
and handle prefill by dropping the message into the composer for
editing (mirrors the TUI's createSlashHandler).
Tests: parseCommandDispatch send-notice / prefill cases.
Address review feedback on the keyVar test helper: it mocks one /api/env row
(an EnvVarInfo), so type it as such and mirror the sibling provider() factory's
base-plus-Partial-override shape instead of hardcoding positional args and
fabricated fields (description='X direct API', url=''). Route the WidgetAI test
through it too, removing the inline duplicate of the same object shape.
- API-keys tab: a SearchField filters provider cards by name / env-var key /
description, with a 'no providers match' empty state. Card order stays
priority-then-name (curated PROVIDER_GROUPS priority floats recommended
providers up; equal priority falls back to alphabetical).
- Accounts tab: 'Other providers' keep sortProviders order (priority, then
name) — unchanged.
Adds searchKeys/noKeysMatch i18n strings across all four locales. Vitest covers
priority/name ordering + live filtering + empty state.
buildProviderKeyGroups now groups provider env vars by the backend-supplied
provider/provider_label (from the unified catalog — the same identity hermes
model uses), falling back to the desktop PROVIDER_GROUPS prefix match only when
the backend gives no hint. A provider the backend tags now always renders its
own Keys card, even with no hand-maintained PROVIDER_GROUPS prefix row —
PROVIDER_GROUPS is demoted to a presentation overlay (priority/blurb/docs).
Adds provider/provider_label to EnvVarInfo. New vitest asserts a backend-tagged
provider with no prefix row still renders a card.
Unit-test `storedSessionIdForNotification`: runtime ids resolve to their
stored id, unknown ids and empty maps pass through unchanged, the right
stored id is picked among several sessions, and stored ids (map keys) are
never rewritten.
Native notifications (approval / sudo / secret / clarify) are tagged with
the gateway *runtime* session id — the key under which the session lives in
the gateway's in-memory `_sessions` map and the id every event carries
(`tui_gateway/server.py` `_emit(event, sid, ...)`). The chat route, however,
is keyed by the *stored* session id (`stored_session_id`), which is a
different value: a new chat gets its runtime id immediately but its stored id
only once the first turn persists.
`onFocusSession` navigated straight to `sessionRoute(<runtime id>)`, so
clicking a notification (e.g. an approval prompt) sent the route-resume path a
runtime id where it expects a stored id. `useRouteResume` then resumed it as a
stored session -> REST `/api/sessions/<runtime id>` 404 "session not found",
and the running session was navigated away, which the user experiences as the
session being destroyed.
Translate runtime -> stored before navigating via the existing
`runtimeIdByStoredSessionId` map (new `storedSessionIdForNotification`
helper), falling back to the id as-is when no mapping is known. The
Approve/Reject notification button path is untouched: `approval.respond` is
routed by the runtime id (`_sess()` -> `_sessions[session_id]`), so it must
keep carrying the runtime id.
The desktop model picker had no way to force a fresh model fetch: model.options
went through the 1h-cached provider_models_cache.json, and there was no flag to
bust it. When a provider's cached list expired and its next live fetch failed,
the picker fell back to the curated static list — silently dropping live-only
models (e.g. OpenCode Zen's free tier like deepseek-v4-flash-free) the user had
been using.
- Thread refresh through model.options (RPC + REST /api/model/options) ->
build_models_payload -> list_authenticated_providers, which calls
clear_provider_models_cache() up front when set so every row re-fetches live.
- Add a 'Refresh Models' control to the desktop picker (5-locale i18n, spinning
sync icon). Normal opens leave refresh=false to stay snappy on the cache.
Verified: stale cache hides deepseek-v4-flash-free -> refresh busts it -> live
re-fetch surfaces it. refresh=false never touches the cache.
* fix(desktop): show Hindsight memory provider
* feat(desktop): configure Hindsight memory provider
* fix(desktop): limit Hindsight modes to supported setup
* refactor(desktop): generic memory-provider config surface
Replace the bespoke Hindsight settings surface with a declarative,
schema-driven path so adding a memory provider is pure declaration —
no per-provider page, conditional, or endpoint.
- memory_providers.py: declarative registry. Each provider lists its
fields {key, label, kind, default, options, secret-vs-plain}. Hindsight's
mode is a select(cloud, local_external), so rejecting local_embedded
falls out of generic enum validation instead of a hand-written check.
- One generic endpoint pair GET/PUT /api/memory/providers/{name}/config.
GET returns declared fields + current values (secrets only as is_set,
never read back); PUT validates selects against their options, writes
plain fields to the provider config file, secrets to the env store,
and flips memory.provider.
- ProviderConfigPanel renders straight from the schema, replacing
hindsight-settings.tsx and the memory.provider === 'hindsight'
conditional in config-settings.tsx — same pattern as
toolset-config-panel.tsx off env_vars.
Scoped to memory providers; storage layout is unchanged so the runtime
Hindsight plugin reads the same config.json / HINDSIGHT_API_KEY / provider
keys as before. Tests cover the registry, endpoint behavior (defaults,
write+secret, select rejection, unknown provider, secret-never-returned),
and the generic panel.
The "💾 Self-improvement review" summary (skill/memory updated) was invisible
on two surfaces:
- Desktop Electron app had no review.summary event handler — skill/memory
writes happened silently. Now appends a persistent system message to the
transcript (matching the Ink TUI's persistent-line semantics, not a
transient toast that can be missed).
- tui_gateway (backs both 'hermes --tui' and the desktop) never read
display.memory_notifications, so it always behaved as 'on' and ignored a
user who set 'off'/'verbose'. Added _load_memory_notifications() (mirrors
the messaging gateway's bool->str normalization, defaults to 'on') and
wired it to agent.memory_notifications, matching gateway/run.py and the CLI.
Delivery chain now reaches all surfaces:
background_review.py -> background_review_callback -> review.summary event ->
desktop transcript / Ink TUI line / gateway message / CLI print.
The `before-quit` handler tears down the bootstrap controller, preview
watchers, and the Python backend but never disposes live PTY sessions.
When `app.quit()` proceeds to `FreeEnvironment()`, node-pty's
`ThreadSafeFunction::CallJS` callback fires on a half-torn-down
environment, throws a C++ exception that can no longer be caught, and
the process aborts (microsoft/node-pty#904).
Iterate `terminalSessions` and call `disposeTerminalSession()` (which
already calls `pty.kill()` + deletes the map entry) before killing the
backend, so the ThreadSafeFunctions are removed before teardown begins.
Closes#48335
The desktop self-update runs `hermes update` then `hermes desktop
--build-only`, and only relaunches if the rebuild returns 0. The first
`--build-only` can exit nonzero on a still-settling post-update tree or a
network-blocked Electron fetch that the installer's self-heal repaired
mid-run — so both updaters (the Tauri setup binary and the in-app POSIX
path) bailed before the relaunch step. The update landed but the app
never restarted; a manual launch worked because the heal had completed.
Retry `--build-only` once in both paths before failing, mirroring the
retry-once `hermes update` already does (and the CLI `hermes update`'s
own desktop rebuild). A second run builds clean off the healed dist and
is a near-no-op when the first actually succeeded (content-hash stamp).
- update.rs: retry stage 2; add rebuild_needs_retry() + test
- main.cjs: retry via new update-rebuild.cjs helper (behavior-tested)
* fix(desktop): resolve electronDist dynamically + self-heal blocked installs
Supersedes the static-path approach (#48081) and the install-step self-heal
(#48082) with a fix that removes the whole failure class instead of chasing each
symptom. Three distinct faults converged into the June desktop-build outage; this
closes all three.
Root cause (the part #48081 left open — "Gap B"):
build.electronDist was a static relative path in apps/desktop/package.json, but
npm workspace hoisting is NOT deterministic — depending on the npm version and
what else is installed, npm nests the workspace-only electron devDep under
apps/desktop/node_modules/electron OR hoists it to the repo root. A static path
matches only one layout, so a clean install intermittently fails with "The
specified electronDist does not exist". #48081 re-pointed the path at the
nested layout (correct today) but electron-builder reads electronDist
STATICALLY, so any future hoist change silently breaks it again — only caught
by a CI invariant, never self-corrected.
Fix:
- scripts/run-electron-builder.cjs: resolve electron the way Node's runtime does
— require.resolve("electron/package.json") walks node_modules from the desktop
project upward and finds electron wherever npm actually put it. The path can
never drift out of sync with the install layout again, on any OS/npm version.
* dist present -> pass -c.electronDist=<abs>/dist so electron-builder reuses
the unpacked runtime (keeps the #38673 fast path that dodges the 26.8.x
missing-binary re-unpack bug).
* dist absent -> omit electronDist; electron-builder fetches Electron itself
via @electron/get honoring electronVersion + ELECTRON_MIRROR.
package.json: builder script now runs the wrapper; the static build.electronDist
is removed (the resolver owns it).
- main.py / install.sh / install.ps1: on a dependency-install failure where the
electron package staged but its dist is missing (electron's install.js
process.exit(1) on a blocked/throttled binary download — #47266/#47917/#48021),
repopulate the dist via electron's downloader (canonical, then npmmirror.com)
and CONTINUE to the build instead of aborting. npm runs postinstall LAST, so
the only casualty is electron/dist; bailing here is what made the pack-time
mirror self-heal unreachable on a blocked network. Hard-fail only when electron
never staged at all (a genuine dependency error).
- The pack-time mirror fallback now retries the build even when the pre-fetch
can't populate the dist: the wrapper lets electron-builder download Electron
itself via the mirror, so the retry is no longer a no-op (it was, when
electronDist was a static path).
The exact 40.10.2 pin (already on main) keeps the third mode — the native
@electron-internal/extract-zip win32 binding that 40.10.3/40.10.4 ship without a
published prebuild — from recurring.
Tests:
- test_desktop_electron_pin.py: replace the static-path-matches-lockfile
invariant with contracts that there is no hardcoded electronDist to drift, the
builder script routes through the resolver, and the resolver uses Node module
resolution + injects -c.electronDist.
- test_gui_command.py: install-failure self-heal continues to build; genuine
(electron-never-staged) install failure still hard-fails; pack retries under
the mirror even when the pre-fetch is blocked.
Salvages/supersedes the overlapping community work in #48003 (sitkarev),
#48012 (omegazheng), #48033 (james47kjv), and #48082.
Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com>
Co-authored-by: omegazheng <zheng@omegasys.eu>
Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>
* fix(desktop): narrow Electron self-heal to real missing-dist failures
Follow-up on #48091 to remove the remaining misdiagnosis risk from the
installer/build fallback path (#46785 concern): only take the Electron
repair/retry path when Electron's package files are staged and dist is actually
missing/corrupt.
- main.py: add _electron_pkg_staged_missing_dist() and use it to gate install
failure recovery; fail fast for unrelated npm install errors.
- main.py/install.sh/install.ps1: run cache purge + retry only when dist is
missing; do not retry unrelated tsc/vite/build failures under an
Electron-specific narrative.
- install.sh/install.ps1: tighten install-stage self-heal guard to require both
package.json + install.js and missing dist.
- tests: add coverage that install failure hard-fails when Electron dist already
exists, and update retry test to reflect the tightened recovery condition.
Validation:
- Python tests: 64 passed
- install.sh-related tests included in the run
- Real mac build on this machine:
- npm ci at repo root: success
- cd apps/desktop && npm run pack: success
- electron-builder packaged darwin arm64 and used custom unpacked Electron dist
* refactor(desktop): trim electron self-heal helpers and comments
Deduplicate mirror-retry into _try_redownload_electron_dist / shell
counterparts; shorten wrapper and install-script commentary without
changing recovery semantics.
---------
Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com>
Co-authored-by: omegazheng <zheng@omegasys.eu>
Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>