From abbe424e78dc2634fe3670fc976ed58fb74b52e4 Mon Sep 17 00:00:00 2001 From: konsisumer Date: Wed, 29 Jul 2026 14:20:44 +0200 Subject: [PATCH] fix(desktop): harden update handoff home resolution --- apps/desktop/electron/main.ts | 116 ++++++++++++++++++++++++---------- 1 file changed, 81 insertions(+), 35 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 344b149d0d103..de7c44e6d2864 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,3 +1,4 @@ +import type { SpawnOptions } from 'node:child_process' import { execFile, execFileSync, spawn } from 'node:child_process' import crypto from 'node:crypto' import fs from 'node:fs' @@ -564,11 +565,20 @@ function resolveHermesHome() { const HERMES_HOME = resolveHermesHome() -function hermesManagedNodePathEntries() { +function hermesHomeCandidates(hermesHome = HERMES_HOME) { + const candidates = [hermesHome] + const parent = path.dirname(hermesHome) + if (path.basename(parent).toLowerCase() === 'profiles') { + candidates.push(path.dirname(parent)) + } + return [...new Set(candidates)] +} + +function hermesManagedNodePathEntries(hermesHome = HERMES_HOME) { // NOTE: keep this ordering in sync with iter_hermes_node_dirs() in // hermes_constants.py — this Node main process cannot import the Python // module, so the platform-ordering rule is mirrored here. - const root = path.join(HERMES_HOME, 'node') + const root = path.join(hermesHome, 'node') const bin = path.join(root, 'bin') const entries = IS_WINDOWS ? [root, bin] : [bin, root] @@ -579,6 +589,10 @@ function pathWithHermesManagedNode(...entries) { return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH].filter(Boolean).join(path.delimiter) } +function pathWithHermesManagedNodeForHome(hermesHome, ...entries) { + return [...hermesManagedNodePathEntries(hermesHome), ...entries, process.env.PATH].filter(Boolean).join(path.delimiter) +} + // ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path // install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end // up with identical layouts and can share one install. @@ -1746,7 +1760,7 @@ const UPDATE_HANDOFF_DWELL_MS = 2500 // reports as a blocker, aborting every update attempt. function updateGateDeps() { return { - hasLiveMarker: () => Boolean(readLiveUpdateMarker(HERMES_HOME)), + hasLiveMarker: () => hermesHomeCandidates().some(hermesHome => Boolean(readLiveUpdateMarker(hermesHome))), isUpdateInFlight: () => updateInFlight } } @@ -2601,11 +2615,63 @@ let quitConfirmedWithActiveWork = false // the desktop never touches its own bits while running. Returns null when the // updater isn't staged (e.g. a dev/source run that never went through the // installer); callers degrade gracefully. -function resolveUpdaterBinary() { - const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup' - const candidate = path.join(HERMES_HOME, name) +function resolveUpdaterBinary( + candidates = hermesHomeCandidates(), + { isWindows = IS_WINDOWS, exists = fileExists } = {} +) { + const name = isWindows ? 'hermes-setup.exe' : 'hermes-setup' + for (const hermesHome of candidates) { + const candidate = path.join(hermesHome, name) + if (exists(candidate)) { + if (hermesHome !== HERMES_HOME) { + rememberLog(`[updates] resolved staged updater from base Hermes home: ${candidate}`) + } + return candidate + } + } + return null +} + +function updaterHermesHome(updater) { + return updater ? path.dirname(updater) : HERMES_HOME +} - return fileExists(candidate) ? candidate : null +function resolveUpdateRootForHermesHome(hermesHome) { + return hermesHome === HERMES_HOME ? resolveUpdateRoot() : path.join(hermesHome, 'hermes-agent') +} + +function updateMarkerHomes(primaryHermesHome, candidates = hermesHomeCandidates()) { + return [...new Set([primaryHermesHome, ...candidates].filter(Boolean))] +} + +function writeUpdateMarkers(primaryHermesHome, pid) { + const homes = updateMarkerHomes(primaryHermesHome) + for (const hermesHome of homes) { + writeUpdateMarker(hermesHome, pid) + } +} + +function updaterHandoffContext(updater) { + const handoffHome = updaterHermesHome(updater) + const updateRoot = resolveUpdateRootForHermesHome(handoffHome) + const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') + + return { + handoffHome, + updateRoot, + venvBin, + spawnOptions: { + cwd: handoffHome, + env: { + ...process.env, + HERMES_HOME: handoffHome, + PATH: pathWithHermesManagedNodeForHome(handoffHome, venvBin) + }, + detached: true, + stdio: 'ignore', + windowsHide: false + } + } } function repairMacUpdaterHelper(updater) { @@ -2884,7 +2950,8 @@ async function applyUpdates(opts = {}) { }) repairMacUpdaterHelper(updater) - const updateRoot = resolveUpdateRoot() + const handoff = updaterHandoffContext(updater) + const { handoffHome, updateRoot, venvBin } = handoff const { branch: configuredBranch } = readDesktopUpdateConfig() const branch = await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) const updaterArgs = ['--update', '--branch', branch] @@ -2894,13 +2961,10 @@ async function applyUpdates(opts = {}) { updaterArgs.push('--target-app', targetApp) } - const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') - // ── Pre-flight state.db integrity guard (#68474) ───────────────── // Emergency backup and header verification before the update touches // anything. Runs while the backend is still alive. preflightStateDb(HERMES_HOME, rememberLog) - // Stop our own backend(s) and wait for the venv shim to unlock BEFORE we // spawn the updater. Without this the updater races a still-locked // hermes.exe (held by the backend child / its grandchildren) and the update @@ -2958,16 +3022,7 @@ async function applyUpdates(opts = {}) { // Detached so the updater outlives this process — it needs us GONE before // `hermes update` will run (the venv shim is locked while we live). - const child = spawnUpdaterProcess(updater, updaterArgs, { - cwd: HERMES_HOME, - env: { - ...process.env, - HERMES_HOME, - PATH: pathWithHermesManagedNode(venvBin) - }, - detached: true, - stdio: 'ignore' - }) + const child = spawnUpdaterProcess(updater, updaterArgs, handoff.spawnOptions as SpawnOptions) // Write the update-in-progress marker IMMEDIATELY — before the 2.5s // quit dwell. The Tauri updater won't write its own marker for several @@ -2977,7 +3032,7 @@ async function applyUpdates(opts = {}) { // waitForUpdateToFinish() gate sees a live update and parks instead. // The updater overwrites this with its own PID later; same format. if (Number.isInteger(child.pid)) { - writeUpdateMarker(HERMES_HOME, child.pid) + writeUpdateMarkers(handoffHome, child.pid) } rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`) @@ -3009,14 +3064,14 @@ async function handOffWindowsBootstrapRecovery(reason) { return false } - const updateRoot = resolveUpdateRoot() + const handoff = updaterHandoffContext(updater) + const { handoffHome, updateRoot, venvBin } = handoff const { branch: configuredBranch } = readDesktopUpdateConfig() const branch = directoryExists(path.join(updateRoot, '.git')) ? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) : configuredBranch || DEFAULT_UPDATE_BRANCH - const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') @@ -3033,22 +3088,13 @@ async function handOffWindowsBootstrapRecovery(reason) { await releaseBackendLockForUpdate(updateRoot) - const child = spawnUpdaterProcess(updater, updaterArgs, { - cwd: HERMES_HOME, - env: { - ...process.env, - HERMES_HOME, - PATH: pathWithHermesManagedNode(venvBin) - }, - detached: true, - stdio: 'ignore' - }) + const child = spawnUpdaterProcess(updater, updaterArgs, handoff.spawnOptions as SpawnOptions) // Same marker pre-write as applyUpdates — see comment there. The recovery // hand-off has the same window where the renderer can respawn a backend // before the updater writes its own marker. if (Number.isInteger(child.pid)) { - writeUpdateMarker(HERMES_HOME, child.pid) + writeUpdateMarkers(handoffHome, child.pid) } rememberLog(