diff --git a/apps/desktop/electron/handoff-result.test.ts b/apps/desktop/electron/handoff-result.test.ts index 4d6f7b1cfe11a..6d74f7f64965f 100644 --- a/apps/desktop/electron/handoff-result.test.ts +++ b/apps/desktop/electron/handoff-result.test.ts @@ -67,3 +67,49 @@ test('malformed JSON is consumed silently', () => { test('absent file returns null', () => { assert.equal(readAndConsumeHandoffResult(tempHome()), null) }) + +test('manual flag survives the round trip and defaults false', () => { + const home = tempHome() + write(home, { + ok: true, + exit_code: 0, + manual: true, + message: 'Update complete. Reopen Hermes to finish (it could not restart itself).', + branch: 'main', + finished_at: Math.floor(Date.now() / 1000) + }) + + const result = readAndConsumeHandoffResult(home) + + assert.ok(result) + assert.equal(result.ok, true) + assert.equal(result.manual, true) + + write(home, { ok: true, exit_code: 0, message: 'done', branch: 'main', finished_at: Math.floor(Date.now() / 1000) }) + assert.equal(readAndConsumeHandoffResult(home)?.manual, false, 'older writers without the field parse as manual:false') +}) + +test('an old manual result survives the freshness window but an old ordinary one does not', () => { + const stale = Math.floor(Date.now() / 1000) - 3600 + + const ordinary = tempHome() + write(ordinary, { ok: true, exit_code: 0, manual: false, message: 'done', branch: 'main', finished_at: stale }) + assert.equal(readAndConsumeHandoffResult(ordinary), null, 'a stale ordinary result is discarded') + assert.equal(fs.existsSync(handoffResultPath(ordinary)), false, 'and still consumed') + + const home = tempHome() + write(home, { + ok: true, + exit_code: 0, + manual: true, + message: 'Update complete. Reopen Hermes to finish (it could not restart itself).', + branch: 'main', + finished_at: stale + }) + + const result = readAndConsumeHandoffResult(home) + + assert.ok(result, 'a stale manual result is still surfaced — it is the last-resort channel') + assert.equal(result.manual, true) + assert.equal(readAndConsumeHandoffResult(home), null, 'but only once') +}) diff --git a/apps/desktop/electron/handoff-result.ts b/apps/desktop/electron/handoff-result.ts index c724be66e3118..ada072b5178db 100644 --- a/apps/desktop/electron/handoff-result.ts +++ b/apps/desktop/electron/handoff-result.ts @@ -1,14 +1,22 @@ /** * Consume the detached update hand-off's result file (#82328 follow-up). * - * scripts/desktop-update.ps1 runs hidden/detached — the user never sees its + * scripts/desktop-update/windows.ps1 runs hidden/detached — the user never sees its * console. It writes HERMES_HOME/.hermes-update-result.json on every exit * path; the relaunched Desktop reads it exactly once on boot and surfaces * failures (a silent failed update looks identical to "nothing happened", * which is how the 2026-08-09 'closed the app then nothing' report was - * born). Read-and-delete so a result is reported at most once; results - * older than the freshness window are discarded unread (a stale file from a - * crashed relaunch chain must not resurface days later). + * born). Read-and-delete so a result is reported at most once; ordinary + * results older than the freshness window are discarded unread (a stale + * file from a crashed relaunch chain must not resurface days later). + * + * manual:true results are exempt from the freshness window. They are the + * durable action-required channel — on a browserless Linux box with no + * working notifier, the boot dialog is the FIRST and ONLY place the message + * ever surfaces, and the user may not reopen Hermes within 30 minutes. + * Dropping it as stale strands exactly the machine it exists to serve. It is + * still consumed once (the file is unlinked before any age check), so it + * cannot resurface on a later boot. */ import fs from 'fs' @@ -19,6 +27,11 @@ export const HANDOFF_RESULT_MAX_AGE_MS = 30 * 60 * 1000 export interface HandoffResult { ok: boolean exitCode: number + /** Update succeeded but the user must act (reopen the app, reinstall the + * GUI package, fix the sandbox helper). The consumer must SURFACE these — + * an ok:true manual result that only gets logged never reaches the user + * on exactly the machines where no shim/notifier could show it live. */ + manual: boolean message: string branch: string } @@ -56,15 +69,24 @@ export function readAndConsumeHandoffResult( return null } + const manual = Boolean(parsed?.manual) const finishedAt = Number(parsed?.finished_at) - if (!Number.isFinite(finishedAt) || now() - finishedAt * 1000 > maxAgeMs) { + if (!Number.isFinite(finishedAt)) { + return null + } + + // Ordinary results expire; a manual (action-required) result never does — + // it's the last-resort surface for machines with no live channel, so the + // user must see it whenever they next reopen, not only within the window. + if (!manual && now() - finishedAt * 1000 > maxAgeMs) { return null } return { ok: Boolean(parsed?.ok), exitCode: Number.isFinite(Number(parsed?.exit_code)) ? Number(parsed.exit_code) : 1, + manual, message: typeof parsed?.message === 'string' ? parsed.message : '', branch: typeof parsed?.branch === 'string' ? parsed.branch : '' } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 0a2c4a968584e..e0a5cc25e3fc4 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -197,20 +197,13 @@ import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeig import { resolveBehindCount, shouldCountCommits } from './update-count' import { waitForUpdateClearance } from './update-gate' import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker' -import { runRebuildWithRetry } from './update-rebuild' -import { - buildRelaunchScript, - collectRelaunchArgs, - collectRelaunchEnv, - decideRelaunchOutcome, - resolveUnpackedRelease, - sandboxFallbackFromEnv, - sandboxPreflight -} from './update-relaunch' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' import { + collectRelaunchArgs, + resolvePosixScriptHandoff, resolveStagedUpdaterBinary, resolveUpdateScriptHandoff, + sandboxFallbackFromEnv, spawnUpdaterProcess, stagedUpdaterSupportsPrewrittenMarker, wrapHandoffForDetachedConsole @@ -1813,7 +1806,7 @@ async function waitForUpdateToFinish() { timeoutMs: UPDATE_WAIT_TIMEOUT_MS }) - // The detached hand-off script (scripts/desktop-update.ps1) runs hidden; + // The detached hand-off script (scripts/desktop-update/windows.ps1) runs hidden; // its result file is the ONLY way the user learns a detached update // failed. Consume it exactly once, here, right where boot passes the // update gate — success gets a log line, failure gets a real dialog @@ -1822,7 +1815,18 @@ async function waitForUpdateToFinish() { try { const result = readAndConsumeHandoffResult(HERMES_HOME) - if (result && result.ok) { + if (result && result.ok && result.manual) { + // Update landed but the user must act (reopen/reinstall/sandbox). On + // machines with no shim browser and no notifier this dialog is the + // FIRST time the message is visible — it must not be a log line. + rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`) + dialog.showMessageBox({ + type: 'warning', + title: 'Hermes update', + message: 'The update finished, but needs one more step', + detail: result.message + }) + } else if (result && result.ok) { rememberLog(`[updates] detached update finished OK (branch ${result.branch})`) } else if (result) { rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`) @@ -2868,14 +2872,16 @@ async function applyUpdates(opts = {}) { const updater = resolveUpdaterBinary() if (!updater && !IS_WINDOWS) { - // macOS/Linux: never hand off, staged hermes-setup or not — the resolver - // returns null there by policy. Unlike Windows (where a venv-shim file - // lock forces the quit→hand-off→rebuild dance), there's no mandatory file - // locking here, so the desktop can drive the whole update itself: - // `hermes update` (backend) + `hermes desktop --build-only` (OS-aware GUI - // rebuild), then swap the running .app bundle with the freshly built one - // and relaunch. - return await applyUpdatesPosixInApp(opts) + // macOS/Linux: hand off to the repo-owned posix script — same shape as + // Windows (quit → detached orchestrator → `hermes update` → relaunch), + // minus the venv-lock gauntlet POSIX doesn't need. The old in-app + // updater (applyUpdatesPosixInApp) is gone with everything it dragged + // in: the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance (#37532), + // the in-window rebuild retry, and the relaunch-outcome matrix — the + // script owns swap/relaunch, and the app is DEAD during the update so + // there is nothing to reap around. Checkouts that predate the script + // get the manual `hermes update` card once; their next update pulls it. + return await applyUpdatesPosixHandoff(opts) } if (!updater) { @@ -3022,7 +3028,7 @@ async function applyUpdates(opts = {}) { // The staged binary is frozen (no self-update path) and historically runs // months-stale updater logic — pre-#67369 cache resolver, pre-#74782 // marker adoption — producing failures that were fixed on main long ago - // (2026-08-09 incident). scripts/desktop-update.ps1 ships WITH the + // (2026-08-09 incident). scripts/desktop-update/windows.ps1 ships WITH the // checkout, so each `hermes update` refreshes the code that drives the // next one. Checkouts that predate the script fall back to the binary // path unchanged. @@ -3220,56 +3226,6 @@ async function handOffWindowsBootstrapRecovery(reason) { return true } -// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in -// the install we're updating, fall back to `hermes` on PATH. -function resolveHermesCliBinary(updateRoot) { - const venvHermes = path.join(updateRoot, 'venv', 'bin', 'hermes') - - if (fileExists(venvHermes)) { - return venvHermes - } - - return findOnPath('hermes') || null -} - -// Spawn a command and stream each output line to the update progress channel. -function runStreamedUpdate(command, args, { cwd, env, stage }: any = {}) { - return new Promise(resolve => { - let child - - try { - child = spawn( - command, - args, - hiddenWindowsChildOptions({ - cwd, - env: { ...process.env, ...(env || {}) }, - stdio: ['ignore', 'pipe', 'pipe'] - }) - ) - } catch (err) { - resolve({ code: 1, error: err.message }) - - return - } - - const emitLines = chunk => { - for (const line of chunk.toString().split('\n')) { - const trimmed = line.trim() - - if (trimmed) { - emitUpdateProgress({ stage, message: trimmed, percent: null }) - } - } - } - - child.stdout.on('data', emitLines) - child.stderr.on('data', emitLines) - child.once('error', err => resolve({ code: 1, error: err.message })) - child.once('exit', code => resolve({ code })) - }) -} - // The running app's .app bundle (packaged macOS): execPath is // .app/Contents/MacOS/; climb three levels to the bundle root. function runningAppBundle() { @@ -3372,307 +3328,124 @@ function preflightStateDb(hermesHome, rememberLog) { } } -function shellQuote(value) { - return `'${String(value).replace(/'/g, `'\\''`)}'` -} - -// macOS/Linux in-app update: backend (`hermes update`) + OS-aware GUI rebuild -// (`hermes desktop --build-only`), then atomically swap the running .app bundle -// with the freshly built one and relaunch. Degrades to "backend updated, -// restart to load the new GUI" if the swap can't be performed. -async function applyUpdatesPosixInApp(opts: any) { +// macOS/Linux update hand-off: spawn the repo-owned posix orchestrator +// (scripts/desktop-update/posix.sh) detached and QUIT. The script waits us +// out, runs `hermes update`, swaps/relaunches the app bundle, and writes +// .hermes-update-result.json for the relaunched Desktop to surface. It shows +// its own tiny shim window (or nothing, headless) — this process only needs +// to leave. Checkouts that predate the script get the manual card once. +async function applyUpdatesPosixHandoff(opts: any) { const updateRoot = resolveUpdateRoot() - const hermes = resolveHermesCliBinary(updateRoot) + const handoff = resolvePosixScriptHandoff(updateRoot) - if (!hermes) { + if (!handoff) { emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null }) return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot } } + const handoffConflict = updateHandoffConflict(HERMES_HOME) + + if (handoffConflict) { + // Same hazard as the Windows path (#75778): a live foreign updater + // already owns the marker — refuse rather than double-mutate the tree. + rememberLog(`[updates] refusing posix hand-off: ${handoffConflict.message}`) + emitUpdateProgress({ stage: 'error', message: handoffConflict.message, percent: null }) + + return { ok: false, error: 'update-already-running', message: handoffConflict.message } + } + // ── Pre-flight state.db integrity guard (#68474) ── preflightStateDb(HERMES_HOME, rememberLog) - // Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s - // npm build can find them on a machine with no system Node. Windows portable - // Node lives directly under %LOCALAPPDATA%\\hermes\\node, not node\\bin. - // PYTHONUNBUFFERED: `hermes update` writes to a pipe here, so CPython - // block-buffers stdout and long quiet steps (the pre-update backup can zip - // multi-GB archives for minutes) stream nothing to the progress UI — users - // read the silence as a hang and cancel a healthy update. - const env: Record = { - HERMES_HOME, - PYTHONUNBUFFERED: '1', - PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) - } - - // `hermes update` reaps stale `hermes serve` backends (a code update - // leaves the running process serving old Python against the freshly-updated - // JS bundle). But OUR backend is one of those processes, and killing it - // mid-update produces the boot→kill→crash loop in #37532 — the desktop - // already restarts its own backend via the rebuild+relaunch below, so the - // reap must spare it. Hand the live backend's PID to the update process; - // _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes - // it while still reaping any genuinely-orphaned backends. (#37532) - // Exclude every desktop-managed backend (primary + all pool profiles) from - // the update reaper. _kill_stale_dashboard_processes accepts a comma-separated - // list (a single int still parses for back-compat). - const desktopChildPids = [] - const hermesProcess = backendConnectionState.getProcess() - - if (hermesProcess && Number.isInteger(hermesProcess.pid)) { - desktopChildPids.push(hermesProcess.pid) - } - - for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) { - desktopChildPids.push(entry.process.pid) - } - } - - if (desktopChildPids.length) { - env.HERMES_DESKTOP_CHILD_PID = desktopChildPids.join(',') - } - - // Branch-pin so a non-main checkout doesn't get switched to main (and self-heal - // to main when the pinned branch no longer exists on origin). - let branchArgs = [] + // Branch-pin so a non-main checkout doesn't get switched to main (and + // self-heal to main when the pinned branch no longer exists on origin). + let branch = 'main' try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() if (head.code === 0 && current && current !== 'HEAD') { - branchArgs = ['--branch', await resolveHealedBranch(updateRoot, current)] + branch = await resolveHealedBranch(updateRoot, current) } } catch { // best effort } - emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 }) + const args = [ + ...handoff.args, + '--install-root', + updateRoot, + '--branch', + branch, + '--desktop-pid', + String(process.pid) + ] - const updated = (await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { - cwd: updateRoot, - env, - stage: 'update' - })) as any + // Relaunch target: the running .app bundle on mac (script swaps the + // rebuilt bundle over it), the running binary elsewhere. The script's gate + // (an exact port of update-relaunch.ts's decideRelaunchOutcome) relaunches + // only a binary the rebuild replaced with a launchable sandbox helper — + // replaying the original launch context (filtered args, cwd, sandbox + // opt-out) so a deep-link or --no-sandbox launch survives the update. + const targetApp = IS_MAC ? runningAppBundle() : process.execPath - if (updated.code !== 0) { - emitUpdateProgress({ stage: 'error', message: 'hermes update failed.', error: updated.error || 'update-failed' }) - - return { ok: false, error: 'hermes update failed' } + if (targetApp) { + args.push('--relaunch-target', targetApp) } - emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 }) + const relaunchArgs = collectRelaunchArgs(process.argv.slice(1)) - // Retry-once: a first rebuild can fail on a still-settling tree or a - // self-healed (network-blocked) Electron download; a second run builds clean - // off the healed dist so we reach the swap+relaunch below instead of bailing. - const rebuilt = await runRebuildWithRetry(attempt => { - if (attempt > 0) { - emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 }) + if (!IS_MAC) { + args.push('--relaunch-cwd', process.cwd()) + + if (sandboxFallbackFromEnv(process.env, relaunchArgs)) { + args.push('--sandbox-fallback') } - return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' }) + if (relaunchArgs.length) { + args.push('--', ...relaunchArgs) + } + } + + const child = spawnUpdaterProcess(handoff.command, args, { + cwd: HERMES_HOME, + env: { + ...process.env, + HERMES_HOME, + PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) + }, + detached: true, + stdio: 'ignore' }) - if (rebuilt.code !== 0) { - emitUpdateProgress({ - stage: 'error', - message: 'Backend updated, but the desktop rebuild failed. Restart Hermes to retry.', - error: rebuilt.error || 'rebuild-failed' - }) - - return { ok: false, backendUpdated: true, error: 'desktop rebuild failed' } + // Bridge marker (same contract as the Windows hand-off): cover the gap + // until the script claims the marker with its own pid as step 0. If the + // script never starts, the dead pid reads as stale and self-deletes. + if (Number.isInteger(child.pid)) { + writeUpdateMarker(HERMES_HOME, child.pid) } - // Linux in-app update terminal state (#45205). `hermes desktop --build-only` - // rebuilds the unpacked app in place under apps/desktop/release/-unpacked. - // We can only HONESTLY relaunch into the new GUI when the *running* binary IS - // that rebuilt one — i.e. execPath lives under release/-unpacked. The - // outcome is decided by three signals (see update-relaunch.ts): - // - // underUnpacked + sandboxOk → 'relaunch': detached watcher re-execs us in - // place (mirrors the macOS handoff). Without it the update succeeds but - // the app never restarts and the overlay hangs on "applying" forever. - // !underUnpacked → 'guiSkew': the running shell is an AppImage/ - // .deb/.rpm/dev/unresolved binary we did NOT replace. Claiming "loads - // next launch" is a lie (GUI/backend skew, #37541) — surface an - // explicit closeable terminal state telling the user the GUI package - // was NOT changed and must be updated/reinstalled. - // underUnpacked + !sandboxOk → 'manual': we'd be relaunching the rebuilt - // binary, but a fresh rebuild can leave chrome-sandbox without - // root:root + setuid (mode 4755) and Electron then refuses to launch - // ("quit and never came back"). DO NOT quit into a dead app — keep the - // working window and surface the closeable manual-restart state. - if (!IS_MAC) { - const unpackedDir = resolveUnpackedRelease(process.execPath, updateRoot, process.platform) - const underUnpacked = unpackedDir !== null - - const preflight = underUnpacked - ? sandboxPreflight(unpackedDir, p => fs.statSync(p)) - : { ok: false, reason: 'not-under-unpacked', path: null } - - const sandboxFallback = sandboxFallbackFromEnv(process.env, process.argv.slice(1)) - const sandboxOk = preflight.ok || sandboxFallback - - if (underUnpacked && !preflight.ok) { - rememberLog( - `[updates] sandbox preflight: not launchable (${preflight.reason}) at ${preflight.path}; ` + - `fallback=${sandboxFallback ? 'env/--no-sandbox' : 'none'}` - ) - } - - const outcome = decideRelaunchOutcome({ underUnpacked, sandboxOk }) - - if (outcome === 'relaunch') { - emitUpdateProgress({ stage: 'restart', message: 'Restarting Hermes…', percent: 100 }) - // Preserve launch context across the re-exec: replay the original args - // (filtered of Electron internals) and the env/cwd that define which - // backend/profile/root this instance talks to. Without this the - // relaunched instance comes up with default context instead of the user's. - const relaunchArgs = collectRelaunchArgs(process.argv.slice(1)) - const relaunchEnv = collectRelaunchEnv(process.env) - - const relaunchScript = buildRelaunchScript({ - pid: process.pid, - execPath: process.execPath, - args: relaunchArgs, - env: relaunchEnv, - cwd: process.cwd() - }) - - const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) - - try { - fs.writeFileSync(scriptPath, relaunchScript, { mode: 0o755 }) - const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' }) - child.unref() - rememberLog( - `[updates] launched linux relaunch: ${scriptPath} -> ${process.execPath} ` + - `(args=${relaunchArgs.length}, env=${Object.keys(relaunchEnv).length})` - ) - isQuittingForHandoff = true - setTimeout(() => app.quit(), UPDATE_HANDOFF_DWELL_MS) - - return { ok: true, handedOff: true } - } catch (err) { - rememberLog(`[updates] linux relaunch failed: ${err.message}; falling back to manual restart`) - - return { - ok: true, - backendUpdated: true, - guiUpdated: false, - manualRestart: true, - message: 'Backend updated. Quit and reopen Hermes to load the new version.' - } - } - } - - if (outcome === 'guiSkew') { - emitUpdateProgress({ - stage: 'guiSkew', - message: - 'Backend updated, but the desktop app package was not changed. ' + - 'Update or reinstall the Hermes desktop app to match.', - percent: 100 - }) - rememberLog( - `[updates] gui/backend skew: execPath ${process.execPath} not under release/*-unpacked; ` + - 'backend updated, GUI package unchanged (AppImage/.deb/.rpm/dev/unresolved)' - ) - - return { ok: true, backendUpdated: true, guiUpdated: false, guiSkew: true } - } - - // outcome === 'manual': we're the rebuilt binary, but its sandbox helper is - // not launchable and no fallback applies. Keep this working window alive. - rememberLog( - `[updates] sandbox not launchable (${preflight.reason}); skipping auto-relaunch, ` + - 'returning manual-restart so the user keeps a working window' - ) - - return { - ok: true, - backendUpdated: true, - guiUpdated: false, - manualRestart: true, - sandboxBlocked: true, - message: - 'Backend updated. The rebuilt app can’t relaunch automatically ' + - '(sandbox helper needs root). Quit and reopen Hermes to finish.' - } - } - - const rebuiltApp = [ - path.join(updateRoot, 'apps', 'desktop', 'release', 'mac-arm64', 'Hermes.app'), - path.join(updateRoot, 'apps', 'desktop', 'release', 'mac', 'Hermes.app') - ].find(directoryExists) - - const targetApp = runningAppBundle() - - // No bundle to swap (dev run, Linux AppImage, or unresolved paths): the - // backend is updated; the next launch picks up the rebuilt GUI. - if (!rebuiltApp || !targetApp) { - emitUpdateProgress({ - stage: 'done', - message: 'Backend updated. Restart Hermes to load the new version.', - percent: 100 - }) - - return { ok: true, backendUpdated: true, rebuiltApp: rebuiltApp || null } - } - - emitUpdateProgress({ stage: 'restart', message: 'Installing the updated app and restarting…', percent: 95 }) - - // Detached swapper: wait for THIS process to exit (so the bundle is free), - // ditto the rebuilt app over the running one, clear quarantine, relaunch. - const swapScript = `#!/bin/bash -set -u -APP_PID=${process.pid} -SRC=${shellQuote(rebuiltApp)} -DST=${shellQuote(targetApp)} -for _ in $(seq 1 240); do - kill -0 "$APP_PID" 2>/dev/null || break - sleep 0.5 -done -if [ "$SRC" != "$DST" ]; then - if /usr/bin/ditto "$SRC" "$DST.hermes-update-new"; then - rm -rf "$DST.hermes-update-old" 2>/dev/null || true - mv "$DST" "$DST.hermes-update-old" 2>/dev/null || rm -rf "$DST" - mv "$DST.hermes-update-new" "$DST" - rm -rf "$DST.hermes-update-old" 2>/dev/null || true - fi -fi -/usr/bin/xattr -dr com.apple.quarantine "$DST" 2>/dev/null || true -/usr/bin/open "$DST" -` - - const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) - - try { - fs.writeFileSync(scriptPath, swapScript, { mode: 0o755 }) - } catch (err) { - emitUpdateProgress({ - stage: 'done', - message: 'Backend + app updated. Restart Hermes to load the new version.', - percent: 100 - }) - rememberLog(`[updates] could not write swap script: ${err.message}; rebuilt app at ${rebuiltApp}`) - - return { ok: true, backendUpdated: true, rebuiltApp } - } - - const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' }) - child.unref() - rememberLog(`[updates] launched mac swap+relaunch: ${scriptPath} (${rebuiltApp} -> ${targetApp})`) + rememberLog( + `[updates] launched posix hand-off: ${handoff.scriptPath} (branch ${branch}); quitting to hand off` + ) + emitUpdateProgress({ + stage: 'restart', + message: + 'Updating Hermes — this window will close. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.', + percent: 100 + }) isQuittingForHandoff = true - setTimeout(() => app.quit(), 600) + setTimeout(() => { + app.quit() + }, UPDATE_HANDOFF_DWELL_MS) - return { ok: true, handedOff: true, rebuiltApp, targetApp } + return { ok: true, handedOff: true, updater: handoff.scriptPath } } + function readJson(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')) diff --git a/apps/desktop/electron/update-rebuild.test.ts b/apps/desktop/electron/update-rebuild.test.ts deleted file mode 100644 index 6c2d75245500a..0000000000000 --- a/apps/desktop/electron/update-rebuild.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Tests for electron/update-rebuild.ts — the retry-once policy for the desktop - * `--build-only` rebuild during self-update. - * - * Run with: node --test electron/update-rebuild.test.ts - * (Wired into npm test:desktop:platforms in package.json.) - * - * Why this matters: a first rebuild can return nonzero on a still-settling tree - * or a self-healed (network-blocked) Electron download. Without a second attempt - * the updater bails before the relaunch step — the app updates but never restarts - * (the field report behind this fix). The retry must fire on failure, not on - * success, and must run at most twice. - */ - -import assert from 'node:assert/strict' - -import { test } from 'vitest' - -import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild' - -test('shouldRetryRebuild retries only on a non-success exit', () => { - assert.equal(shouldRetryRebuild(0), false) - assert.equal(shouldRetryRebuild(1), true) - assert.equal(shouldRetryRebuild(null), true) -}) - -test('a clean first rebuild runs once and does not retry', async () => { - const codes = [] - - const result = await runRebuildWithRetry(attempt => { - codes.push(attempt) - - return Promise.resolve({ code: 0 }) - }) - - assert.deepEqual(codes, [0]) - assert.equal(result.code, 0) -}) - -test('a failed first rebuild retries once and succeeds', async () => { - const codes = [] - - const result = await runRebuildWithRetry(attempt => { - codes.push(attempt) - - return Promise.resolve({ code: attempt === 0 ? 1 : 0 }) - }) - - assert.deepEqual(codes, [0, 1]) - assert.equal(result.code, 0) -}) - -test('a rebuild that keeps failing runs at most twice and reports the failure', async () => { - const codes = [] - - const result = await runRebuildWithRetry(attempt => { - codes.push(attempt) - - return Promise.resolve({ code: 1, error: 'rebuild-failed' }) - }) - - assert.deepEqual(codes, [0, 1]) - assert.equal(result.code, 1) - assert.equal(result.error, 'rebuild-failed') -}) diff --git a/apps/desktop/electron/update-rebuild.ts b/apps/desktop/electron/update-rebuild.ts deleted file mode 100644 index a2a3581eccd8e..0000000000000 --- a/apps/desktop/electron/update-rebuild.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Retry-once policy for the desktop `--build-only` rebuild during self-update. - * - * The first rebuild can return nonzero on a still-settling post-update tree or a - * network-blocked Electron fetch that the installer's self-heal repaired mid-run. - * A second attempt then builds clean off the healed dist (the content-hash stamp - * makes it a near-no-op when the first actually succeeded). Without the retry the - * updater bails before the relaunch step — the app updates but doesn't restart. - */ - -function shouldRetryRebuild(code) { - return code !== 0 -} - -/** - * Run `rebuild()` (async, resolves `{ code, ... }`), retrying once on failure. - * Returns the final result. - */ -async function runRebuildWithRetry(rebuild) { - let result = await rebuild(0) - - if (shouldRetryRebuild(result.code)) { - result = await rebuild(1) - } - - return result -} - -export { runRebuildWithRetry, shouldRetryRebuild } diff --git a/apps/desktop/electron/update-relaunch.test.ts b/apps/desktop/electron/update-relaunch.test.ts deleted file mode 100644 index 54e42eabf9b2a..0000000000000 --- a/apps/desktop/electron/update-relaunch.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Tests for electron/update-relaunch.ts — the pure decision + script helpers - * behind the Linux in-app update relaunch (#45205). - * - * Run with: node --test electron/update-relaunch.test.ts - * (Wired into npm test:desktop:platforms in package.json.) - * - * What this locks (review acceptance criteria for PR #45205): - * 1. The execPath split: only a binary under release/-unpacked may - * relaunch/claim a GUI update; AppImage/.deb/.rpm/dev/unresolved paths land - * on the guiSkew terminal state and do NOT claim the GUI was updated. - * 2. Launch context is replayed on re-exec (args filtered of Electron - * internals; HERMES_HOME / HERMES_DESKTOP_* env + cwd preserved) and is - * safely shell-quoted. - * 3. The sandbox preflight: chrome-sandbox must be root-owned + setuid to be - * launchable; otherwise the decision degrades to a manual terminal state - * (keep a working window) unless a non-interactive fallback applies. - */ - -import assert from 'node:assert/strict' -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { test } from 'vitest' - -import { - buildRelaunchScript, - collectRelaunchArgs, - collectRelaunchEnv, - decideRelaunchOutcome, - resolveUnpackedRelease, - sandboxFallbackFromEnv, - sandboxPreflight, - shellQuote, - unpackedDirName -} from './update-relaunch' - -const ROOT = '/home/u/.hermes/hermes-agent' -const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked') - -// --------------------------------------------------------------------------- -// 1) The execPath split — the heart of the GUI/backend skew guard. -// --------------------------------------------------------------------------- - -test('unpackedDirName maps platform to the electron-builder dir', () => { - assert.equal(unpackedDirName('linux'), 'linux-unpacked') - assert.equal(unpackedDirName('win32'), 'win-unpacked') -}) - -test('resolveUnpackedRelease returns the dir for a binary UNDER release/-unpacked', () => { - const exec = path.join(UNPACKED, 'hermes') - assert.equal(resolveUnpackedRelease(exec, ROOT, 'linux'), UNPACKED) - // The unpacked dir itself also counts. - assert.equal(resolveUnpackedRelease(UNPACKED, ROOT, 'linux'), UNPACKED) -}) - -test('resolveUnpackedRelease is null for AppImage / .deb / .rpm / dev / unresolved paths', () => { - // AppImage mount - assert.equal(resolveUnpackedRelease('/tmp/.mount_Hermes12345/AppRun', ROOT, 'linux'), null) - // .deb / .rpm system install - assert.equal(resolveUnpackedRelease('/usr/lib/hermes/hermes', ROOT, 'linux'), null) - assert.equal(resolveUnpackedRelease('/opt/Hermes/hermes', ROOT, 'linux'), null) - // dev electron - assert.equal( - resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'), - null - ) - // empty / missing - assert.equal(resolveUnpackedRelease('', ROOT, 'linux'), null) - assert.equal(resolveUnpackedRelease(path.join(UNPACKED, 'hermes'), '', 'linux'), null) -}) - -test('resolveUnpackedRelease is not fooled by a sibling prefix dir', () => { - // `.../release/linux-unpacked-evil` must NOT match `.../release/linux-unpacked`. - const sneaky = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked-evil', 'hermes') - assert.equal(resolveUnpackedRelease(sneaky, ROOT, 'linux'), null) -}) - -test('decideRelaunchOutcome: only under-unpacked + sandbox-ok relaunches', () => { - assert.equal(decideRelaunchOutcome({ underUnpacked: true, sandboxOk: true }), 'relaunch') - // Under unpacked but sandbox not launchable → manual (keep a working window). - assert.equal(decideRelaunchOutcome({ underUnpacked: true, sandboxOk: false }), 'manual') - // Not under unpacked → guiSkew regardless of sandbox flag. - assert.equal(decideRelaunchOutcome({ underUnpacked: false, sandboxOk: true }), 'guiSkew') - assert.equal(decideRelaunchOutcome({ underUnpacked: false, sandboxOk: false }), 'guiSkew') -}) - -// --------------------------------------------------------------------------- -// 3) Sandbox preflight -// --------------------------------------------------------------------------- - -const fakeStat = (uid, mode) => () => ({ uid, mode }) - -const throwStat = () => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) -} - -test('sandboxPreflight: root-owned + setuid is launchable', () => { - const r = sandboxPreflight(UNPACKED, fakeStat(0, 0o4755)) - assert.equal(r.ok, true) - assert.equal(r.reason, 'launchable') -}) - -test('sandboxPreflight: not root → not launchable', () => { - const r = sandboxPreflight(UNPACKED, fakeStat(1000, 0o4755)) - assert.equal(r.ok, false) - assert.equal(r.reason, 'not-root') -}) - -test('sandboxPreflight: missing setuid bit → not launchable', () => { - const r = sandboxPreflight(UNPACKED, fakeStat(0, 0o755)) - assert.equal(r.ok, false) - assert.equal(r.reason, 'not-setuid') -}) - -test('sandboxPreflight: neither root nor setuid (the fresh-rebuild trap)', () => { - const r = sandboxPreflight(UNPACKED, fakeStat(1000, 0o755)) - assert.equal(r.ok, false) - assert.equal(r.reason, 'not-root-not-setuid') -}) - -test('sandboxPreflight: no chrome-sandbox helper present → ok (build does not use SUID sandbox)', () => { - const r = sandboxPreflight(UNPACKED, throwStat) - assert.equal(r.ok, true) - assert.equal(r.reason, 'no-sandbox-helper') -}) - -test('sandboxFallbackFromEnv: ELECTRON_DISABLE_SANDBOX / --no-sandbox make a broken sandbox safe', () => { - assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: '1' }, []), true) - assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: 'true' }, []), true) - assert.equal(sandboxFallbackFromEnv({}, ['--no-sandbox']), true) - assert.equal(sandboxFallbackFromEnv({}, ['--foo']), false) - assert.equal(sandboxFallbackFromEnv({}, []), false) - assert.equal(sandboxFallbackFromEnv(null, null), false) -}) - -// --------------------------------------------------------------------------- -// 2) Launch-context preservation -// --------------------------------------------------------------------------- - -test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', () => { - const argv = [ - '--type=renderer', - '--user-data-dir=/tmp/x', - '--enable-features=Foo', - '--field-trial-handle=123', - '--no-sandbox', // sandbox opt-out — KEEP (user/env intent + relaunch fallback) - '--lang=en-US', - 'hermes://open/agent/42', // deep link — keep - '--profile=work', // app flag — keep - '--remote-debugging-port=9222' // internal — drop - ] - - assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work']) - assert.deepEqual(collectRelaunchArgs(undefined), []) -}) - -test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt-out only', () => { - const env = { - HERMES_HOME: '/home/u/.hermes', - HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', - HERMES_DESKTOP_REMOTE_TOKEN: 'secret', - HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes', - HERMES_DESKTOP_APP_NAME: 'HermesSandbox', - ELECTRON_DISABLE_SANDBOX: '1', // sandbox opt-out — preserved - PATH: '/usr/bin', // not preserved - HOME: '/home/u', // not preserved - UNRELATED: 'x' - } - - assert.deepEqual(collectRelaunchEnv(env), { - HERMES_HOME: '/home/u/.hermes', - HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', - HERMES_DESKTOP_REMOTE_TOKEN: 'secret', - HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes', - HERMES_DESKTOP_APP_NAME: 'HermesSandbox', - ELECTRON_DISABLE_SANDBOX: '1' - }) - assert.deepEqual(collectRelaunchEnv(null), {}) -}) - -// --------------------------------------------------------------------------- -// Generated watcher script: safe quoting + valid bash syntax. -// --------------------------------------------------------------------------- - -test('shellQuote neutralizes single quotes and metacharacters', () => { - assert.equal(shellQuote(`a'b`), `'a'\\''b'`) - assert.equal(shellQuote('$(rm -rf /)'), `'$(rm -rf /)'`) -}) - -test('buildRelaunchScript embeds pid/exec/args/env/cwd and is valid bash', () => { - const script = buildRelaunchScript({ - pid: 4242, - execPath: '/home/u/.hermes/hermes-agent/apps/desktop/release/linux-unpacked/Hermes', - args: ['hermes://open/agent/42', "--note=it's fine"], - env: { HERMES_HOME: '/home/u/.hermes', HERMES_DESKTOP_REMOTE_URL: 'http://box:9119' }, - cwd: '/home/u/work dir' - }) - - // Structural assertions. - assert.match(script, /^#!\/bin\/bash/) - assert.match(script, /APP_PID=4242/) - assert.match(script, /kill -9 "\$APP_PID"/) - assert.match(script, /rm -f -- "\$0"/) - // env exports + cwd restore + args replay are present and quoted. - assert.match(script, /export HERMES_HOME='\/home\/u\/\.hermes'/) - assert.match(script, /export HERMES_DESKTOP_REMOTE_URL='http:\/\/box:9119'/) - assert.match(script, /cd '\/home\/u\/work dir'/) - assert.match(script, /exec '.*\/linux-unpacked\/Hermes' 'hermes:\/\/open\/agent\/42' '--note=it'\\''s fine'/) - - // It must be syntactically valid bash (`bash -n`). Write to a temp file and lint. - const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`) - fs.writeFileSync(tmp, script) - - try { - execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) - } finally { - fs.rmSync(tmp, { force: true }) - } -}) - -test('buildRelaunchScript with no args/env still lints clean', () => { - const script = buildRelaunchScript({ - pid: 1, - execPath: '/opt/Hermes/Hermes', - args: [], - env: {}, - cwd: '' - }) - - const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`) - fs.writeFileSync(tmp, script) - - try { - execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) - } finally { - fs.rmSync(tmp, { force: true }) - } - - // exec line has no trailing args. - assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/) -}) diff --git a/apps/desktop/electron/update-relaunch.ts b/apps/desktop/electron/update-relaunch.ts deleted file mode 100644 index 46ea789bbf696..0000000000000 --- a/apps/desktop/electron/update-relaunch.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * update-relaunch.ts — pure decision + script-generation helpers for the - * Linux in-app update relaunch (#45205). - * - * Extracted from main.ts's `applyUpdatesPosixInApp` so the security- and - * correctness-critical "do we relaunch, or land on a manual terminal state?" - * decision is unit-testable without booting Electron (main.ts - * `require('electron')` at load). - * - * Background - * ---------- - * After `hermes update` + `hermes desktop --build-only`, the freshly-rebuilt - * GUI lives under `apps/desktop/release/-unpacked`. We can only honestly - * relaunch into the new GUI when the *running* binary is that rebuilt one — - * i.e. its execPath is under the rebuilt `release/-unpacked` dir. - * - * - Source / unpacked install (execPath under release/-unpacked): - * the running binary IS the thing we just rebuilt → relaunch it in place. - * - AppImage / .deb / .rpm / dev / unresolved (execPath elsewhere): - * the backend was updated but THIS GUI shell was NOT replaced. Claiming - * "the new version loads next launch" is a lie that produces GUI/backend - * skew (#37541): the user keeps running the old GUI against new backend - * code with no path to fix it from inside the app. Surface an explicit - * terminal state telling them the GUI package must be reinstalled. - * - * Sandbox preflight (#3 in the review) - * ------------------------------------ - * A fresh `release/-unpacked` rebuild can leave `chrome-sandbox` without - * the required `root:root` + setuid (mode 4755). Electron then refuses to - * launch with "The SUID sandbox helper binary was found, but is not configured - * correctly" and the relaunch yields "quit and never came back" — a dead app. - * Before we quit+hand off we preflight the rebuilt sandbox helper; if it is NOT - * launchable (and no working non-interactive fallback applies — see - * sandboxFallbackFromEnv) we DO NOT quit. We keep the working window and return - * the closeable manual-restart terminal state instead. - */ - -import path from 'node:path' - -// Map process.platform → electron-builder's `release/-unpacked` name. -function unpackedDirName(platform) { - if (platform === 'darwin') { - return 'mac-unpacked' - } // not used (mac swaps bundles) - - if (platform === 'win32') { - return 'win-unpacked' - } - - return 'linux-unpacked' -} - -/** - * If `execPath` lives under `/apps/desktop/release/-unpacked`, - * return that unpacked dir; otherwise null. A null result means the running - * binary is NOT the thing we just rebuilt (AppImage/.deb/.rpm/dev), so we must - * not claim a GUI relaunch. - * - * Match is a path-segment-aware prefix check (not a bare string startsWith) so - * `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`. - */ -function resolveUnpackedRelease(execPath, updateRoot, platform) { - if (!execPath || !updateRoot) { - return null - } - - const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release') - const unpacked = path.join(releaseDir, unpackedDirName(platform)) - const normalizedExec = path.resolve(String(execPath)) - // execPath must be the unpacked dir itself or a descendant of it. - const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep - - if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) { - return unpacked - } - - return null -} - -/** - * Pure decision: given whether the running binary is under the rebuilt - * unpacked release AND whether its sandbox helper is launchable, choose the - * terminal outcome. - * - * 'relaunch' — quit + detached watcher re-execs the rebuilt binary in place. - * 'guiSkew' — backend updated, GUI package NOT changed; user must reinstall - * the GUI. Closeable terminal state; does NOT claim a GUI update. - * 'manual' — running the rebuilt binary, but its sandbox helper is not - * launchable and no fallback applies; do NOT quit into a dead - * app. Closeable manual-restart terminal state. - */ -function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { - if (!underUnpacked) { - return 'guiSkew' - } - - if (!sandboxOk) { - return 'manual' - } - - return 'relaunch' -} - -/** - * Preflight the rebuilt sandbox helper. Returns - * { ok: boolean, reason: string, path: string } - * - * `ok` is true when chrome-sandbox is owned by uid 0 AND has the setuid bit - * (mode & 0o4000) — i.e. Electron can launch it. If chrome-sandbox does not - * exist at all we treat it as ok: this Electron build does not use the SUID - * sandbox helper (e.g. it ships the namespace sandbox), so the relaunch is not - * blocked on it. - * - * `statSync` is injectable so this is testable without a real setuid file. - */ -function sandboxPreflight(unpackedDir, statSync) { - if (!unpackedDir) { - return { ok: false, reason: 'no-unpacked-dir', path: null } - } - - const sandboxPath = path.join(unpackedDir, 'chrome-sandbox') - let st - - try { - st = statSync(sandboxPath) - } catch { - // No chrome-sandbox helper present → this build doesn't rely on the SUID - // sandbox; nothing to block the relaunch. - return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath } - } - - const ownedByRoot = st.uid === 0 - const hasSetuid = (st.mode & 0o4000) !== 0 - - if (ownedByRoot && hasSetuid) { - return { ok: true, reason: 'launchable', path: sandboxPath } - } - - if (!ownedByRoot && !hasSetuid) { - return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath } - } - - if (!ownedByRoot) { - return { ok: false, reason: 'not-root', path: sandboxPath } - } - - return { ok: false, reason: 'not-setuid', path: sandboxPath } -} - -/** - * Detect a non-interactive sandbox fallback the user has opted into via the - * environment. The reviewer asked us to integrate with any existing - * `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing - * non-interactive sandbox fallback in the desktop app (the only chrome-sandbox - * reference is documentation in scripts/before-pack.ts). The one signal that - * DOES exist is the standard Electron escape hatch: ELECTRON_DISABLE_SANDBOX=1 - * (and the equivalent `--no-sandbox` already present in the launch args). If - * the user has set that, the rebuilt binary will start even with a broken - * chrome-sandbox, so the relaunch is safe. - * - * Returns true when a fallback makes the relaunch safe despite a failed - * sandbox preflight. - */ -function sandboxFallbackFromEnv(env, launchArgs) { - const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim() - - if (disable === '1' || disable.toLowerCase() === 'true') { - return true - } - - if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) { - return true - } - - return false -} - -// POSIX single-quote a value for safe inclusion in the generated bash script. -function shellQuote(value) { - return `'${String(value).replace(/'/g, `'\\''`)}'` -} - -// Electron / Chromium internal switches that must NOT be replayed on re-exec: -// they are runtime artifacts of THIS launch, not user intent, and re-passing -// them can change sandbox/zygote behavior or point at stale fds/dirs. -const INTERNAL_ARG_PREFIXES = [ - '--type=', // renderer/gpu/zygote child markers - '--user-data-dir=', - '--enable-features=', - '--disable-features=', - '--field-trial-handle=', - '--enable-logging', - '--log-file=', - // NB: --no-sandbox is deliberately NOT stripped — it reflects the user's / - // environment's SUID-sandbox opt-out (some hardened kernels/containers require - // it) and is the signal sandboxFallbackFromEnv() uses to allow a relaunch when - // chrome-sandbox isn't setuid. Dropping it would make exactly that relaunch - // fail ("quit and never came back"). - '--disable-gpu-sandbox', - '--lang=', - '--inspect', - '--remote-debugging-port=' -] - -/** - * Filter Electron internals out of the original launch args so we replay only - * meaningful user/launcher intent (deep-link URLs, app-specific flags). - * `argv` is expected to be process.argv.slice(1) for a PACKAGED app (argv[0] is - * the exec path itself; there is no entry-script arg as in a dev run). - */ -function collectRelaunchArgs(argv) { - if (!Array.isArray(argv)) { - return [] - } - - return argv.filter(arg => { - if (typeof arg !== 'string' || arg.length === 0) { - return false - } - - return !INTERNAL_ARG_PREFIXES.some(prefix => - prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=') - ) - }) -} - -// Env keys whose values define the relaunched instance's context (which -// backend/profile/root it talks to). Anything HERMES_DESKTOP_* is preserved -// plus HERMES_HOME. We snapshot the values, not the live env, so the new -// instance comes up pointed at the same place this one was. -// ELECTRON_DISABLE_SANDBOX is preserved for the same reason --no-sandbox is kept -// in the replayed args: if a relaunch is only safe because the user opted out of -// the SUID sandbox, the relaunched instance must inherit that opt-out too. -const PRESERVED_ENV_KEYS = ['HERMES_HOME', 'ELECTRON_DISABLE_SANDBOX'] -const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_'] - -function collectRelaunchEnv(env) { - const out = {} - - if (!env || typeof env !== 'object') { - return out - } - - for (const [key, value] of Object.entries(env)) { - if (value == null) { - continue - } - - if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) { - out[key] = String(value) - } - } - - return out -} - -/** - * Build the detached bash watcher that waits for the parent to exit (graceful - * window then SIGKILL), self-deletes, and re-execs the rebuilt binary WITH the - * original launch context (cwd, env, args) restored. - * - * @param {object} o - * @param {number} o.pid parent (this) process pid to wait on - * @param {string} o.execPath binary to re-exec - * @param {string[]} o.args filtered launch args to replay - * @param {object} o.env env key→value to export before exec - * @param {string} o.cwd working directory to restore - */ -function buildRelaunchScript({ pid, execPath, args, env, cwd }) { - const exports = Object.entries(env || {}) - .map(([k, v]) => `export ${k}=${shellQuote(v)}`) - .join('\n') - - const quotedArgs = (args || []).map(shellQuote).join(' ') - const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : '' - - // NOTE: `exec` replaces the watcher process with the relaunched app, so the - // re-exec inherits exactly the env/cwd we set above. - return `#!/bin/bash -set -u -APP_PID=${Number(pid)} -# Wait up to ~30s for a graceful exit, then SIGKILL: a hung/zombie parent must -# be gone before we relaunch, or the new instance bails on the single-instance -# lock. (#45205) -for _ in $(seq 1 60); do - kill -0 "$APP_PID" 2>/dev/null || break - sleep 0.5 -done -if kill -0 "$APP_PID" 2>/dev/null; then - kill -9 "$APP_PID" 2>/dev/null || true - sleep 0.5 -fi -# Self-delete so temp watchers don't accumulate across updates. -rm -f -- "$0" 2>/dev/null || true -${cwdLine} -${exports} -exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''} -` -} - -export { - buildRelaunchScript, - collectRelaunchArgs, - collectRelaunchEnv, - decideRelaunchOutcome, - INTERNAL_ARG_PREFIXES, - PRESERVED_ENV_KEYS, - PRESERVED_ENV_PREFIXES, - resolveUnpackedRelease, - sandboxFallbackFromEnv, - sandboxPreflight, - shellQuote, - unpackedDirName -} diff --git a/apps/desktop/electron/updater-process.test.ts b/apps/desktop/electron/updater-process.test.ts index a694b28bc6b74..dbf3a765f7983 100644 --- a/apps/desktop/electron/updater-process.test.ts +++ b/apps/desktop/electron/updater-process.test.ts @@ -5,9 +5,12 @@ import path from 'node:path' import { test } from 'vitest' import { + collectRelaunchArgs, MARKER_SELF_ADOPT_EPOCH_MS, + resolvePosixScriptHandoff, resolveStagedUpdaterBinary, resolveUpdateScriptHandoff, + sandboxFallbackFromEnv, spawnUpdaterProcess, stagedUpdaterSupportsPrewrittenMarker, wrapHandoffForDetachedConsole @@ -170,7 +173,7 @@ test('resolveStagedUpdaterBinary returns null on Windows when nothing is staged' test('resolveUpdateScriptHandoff prefers the repo script on Windows when present', () => { const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent` - const expected = path.join(root, 'scripts', 'desktop-update.ps1') + const expected = path.join(root, 'scripts', 'desktop-update', 'windows.ps1') const handoff = resolveUpdateScriptHandoff(root, { isWindows: true, @@ -183,6 +186,19 @@ test('resolveUpdateScriptHandoff prefers the repo script on Windows when present assert.deepEqual(handoff.args, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', expected]) }) +test('resolveUpdateScriptHandoff falls back to the pre-reorg flat path', () => { + const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent` + const legacy = path.join(root, 'scripts', 'desktop-update.ps1') + + const handoff = resolveUpdateScriptHandoff(root, { + isWindows: true, + fileExists: candidate => candidate === legacy + }) + + assert.ok(handoff) + assert.equal(handoff.scriptPath, legacy) +}) + test('resolveUpdateScriptHandoff returns null when the checkout predates the script', () => { const handoff = resolveUpdateScriptHandoff(String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`, { isWindows: true, @@ -203,7 +219,7 @@ test('resolveUpdateScriptHandoff is Windows-only (POSIX updates in place)', () = test('wrapHandoffForDetachedConsole routes through cmd start with own console', () => { const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent` - const expected = path.join(root, 'scripts', 'desktop-update.ps1') + const expected = path.join(root, 'scripts', 'desktop-update', 'windows.ps1') const handoff = resolveUpdateScriptHandoff(root, { isWindows: true, @@ -233,3 +249,63 @@ test('wrapHandoffForDetachedConsole routes through cmd start with own console', 'main' ]) }) + +test('resolvePosixScriptHandoff returns the bash recipe when the script exists', () => { + const root = '/home/hermes/.hermes/hermes-agent' + const expected = path.join(root, 'scripts', 'desktop-update', 'posix.sh') + + const handoff = resolvePosixScriptHandoff(root, { + isWindows: false, + fileExists: candidate => candidate === expected + }) + + assert.ok(handoff) + assert.equal(handoff.command, '/bin/bash') + assert.deepEqual(handoff.args, [expected]) +}) + +test('resolvePosixScriptHandoff is null when the checkout predates the script', () => { + const handoff = resolvePosixScriptHandoff('/home/hermes/.hermes/hermes-agent', { + isWindows: false, + fileExists: () => false + }) + + assert.equal(handoff, null) +}) + +test('resolvePosixScriptHandoff is null on Windows', () => { + const handoff = resolvePosixScriptHandoff(String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`, { + isWindows: true, + fileExists: () => true + }) + + assert.equal(handoff, null) +}) + +test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', () => { + const argv = [ + '--type=renderer', + '--user-data-dir=/tmp/x', + '--enable-features=A,B', + '--field-trial-handle=123', + '--enable-logging', + '--log-file=/tmp/log', + '--lang=en-US', + '--inspect=9229', + '--remote-debugging-port=9222', + '--no-sandbox', + 'hermes://open/session/abc', + '--profile=work' + ] + + assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/session/abc', '--profile=work']) + assert.deepEqual(collectRelaunchArgs(undefined), []) +}) + +test('sandboxFallbackFromEnv: ELECTRON_DISABLE_SANDBOX / --no-sandbox opt out', () => { + assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: '1' }, []), true) + assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: 'true' }, []), true) + assert.equal(sandboxFallbackFromEnv({}, ['--no-sandbox']), true) + assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: '0' }, []), false) + assert.equal(sandboxFallbackFromEnv({}, []), false) +}) diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index e5728361785a2..ad6ae0d9f075b 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -27,7 +27,7 @@ export interface UpdateScriptHandoff { * updater-side fix only reaches users when a new binary is built, signed and * published — which historically lags main by months and strands users on * long-fixed bugs (cache resolver #67369, marker self-adopt #74782; the - * 2026-08-09 incident chain). `scripts/desktop-update.ps1` lives in the repo + * 2026-08-09 incident chain). `scripts/desktop-update/windows.ps1` lives in the repo * checkout instead: every `hermes update` refreshes the code that drives the * NEXT update, and only PowerShell itself is frozen. * @@ -47,7 +47,50 @@ export function resolveUpdateScriptHandoff( return null } - const scriptPath = path.join(updateRoot, 'scripts', 'desktop-update.ps1') + const exists = deps.fileExists ?? stagedFileExists + + // Current layout first, then the pre-reorg flat path — an updated asar can + // meet a checkout from either side of the move (the checkout also ships a + // forwarder at the legacy path for the inverse skew). + for (const candidate of [ + path.join(updateRoot, 'scripts', 'desktop-update', 'windows.ps1'), + path.join(updateRoot, 'scripts', 'desktop-update.ps1') + ]) { + if (exists(candidate)) { + return { + command: 'powershell', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', candidate], + scriptPath: candidate + } + } + } + + return null +} + +/** + * Repo-owned POSIX update hand-off (the mac/linux twin of the above). + * + * Replaces the in-app posix updater: the Desktop spawns the script detached + * and QUITS, the script waits it out, runs `hermes update`, swaps/relaunches + * the app, and writes .hermes-update-result.json. With the app gone before + * the update starts, the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance is + * unnecessary — there are no live desktop backends to spare. + * + * Null when the checkout predates the script (caller surfaces the manual + * `hermes update` card — old checkouts pull the script on their next update). + */ +export function resolvePosixScriptHandoff( + updateRoot: string, + deps: ResolveUpdateScriptHandoffDeps = {} +): UpdateScriptHandoff | null { + const isWindows = deps.isWindows ?? process.platform === 'win32' + + if (isWindows) { + return null + } + + const scriptPath = path.join(updateRoot, 'scripts', 'desktop-update', 'posix.sh') const exists = deps.fileExists ?? stagedFileExists if (!exists(scriptPath)) { @@ -55,8 +98,8 @@ export function resolveUpdateScriptHandoff( } return { - command: 'powershell', - args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], + command: '/bin/bash', + args: [scriptPath], scriptPath } } @@ -94,6 +137,57 @@ export function wrapHandoffForDetachedConsole( } } +/** + * Electron/Chromium internal switches that must NOT be replayed on re-exec: + * runtime artifacts of THIS launch, not user intent (ported from the deleted + * update-relaunch.ts; #45205). `--no-sandbox` is deliberately kept — it is + * the user's sandbox opt-out and the signal that makes a relaunch safe when + * chrome-sandbox isn't setuid. + */ +export const INTERNAL_ARG_PREFIXES = [ + '--type=', + '--user-data-dir=', + '--enable-features=', + '--disable-features=', + '--field-trial-handle=', + '--enable-logging', + '--log-file=', + '--disable-gpu-sandbox', + '--lang=', + '--inspect', + '--remote-debugging-port=' +] + +/** Filter Electron internals from process.argv.slice(1) so the relaunched + * app replays only user/launcher intent (deep links, app flags). */ +export function collectRelaunchArgs(argv: unknown): string[] { + if (!Array.isArray(argv)) { + return [] + } + + return argv.filter((arg): arg is string => { + if (typeof arg !== 'string' || arg.length === 0) { + return false + } + + return !INTERNAL_ARG_PREFIXES.some(prefix => + prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=') + ) + }) +} + +/** True when the user has opted out of the SUID sandbox — the relaunch is + * safe even if chrome-sandbox fails preflight (ported from update-relaunch.ts). */ +export function sandboxFallbackFromEnv(env: Record, launchArgs: string[]): boolean { + const disable = String(env?.ELECTRON_DISABLE_SANDBOX || '').trim() + + if (disable === '1' || disable.toLowerCase() === 'true') { + return true + } + + return Array.isArray(launchArgs) && launchArgs.includes('--no-sandbox') +} + export interface ResolveStagedUpdaterBinaryDeps { isWindows?: boolean fileExists?: (candidate: string) => boolean diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7e21884540e3a..704a41fb9fb90 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -38,6 +38,13 @@ "dist:win:nsis": "npm run build && npm run builder -- --win nsis", "dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm", "perf": "node scripts/perf/run.mjs", + "update:shim": "bash ../../scripts/desktop-update/repro.sh shim", + "update:shim:fail": "bash ../../scripts/desktop-update/repro.sh shim-fail", + "update:repro:fresh": "bash ../../scripts/desktop-update/repro.sh fresh", + "update:repro:behind": "bash ../../scripts/desktop-update/repro.sh behind", + "update:repro:error": "bash ../../scripts/desktop-update/repro.sh error", + "update:repro:gate": "bash ../../scripts/desktop-update/repro.sh gate", + "update:repro:launch": "bash ../../scripts/desktop-update/repro.sh launch", "perf:serve": "node scripts/perf/serve.mjs", "test:desktop": "node scripts/test-desktop.mjs", "test:desktop:all": "node scripts/test-desktop.mjs all", diff --git a/scripts/desktop-update.ps1 b/scripts/desktop-update.ps1 index d45484501db33..227845ce011f9 100644 --- a/scripts/desktop-update.ps1 +++ b/scripts/desktop-update.ps1 @@ -1,429 +1,11 @@ -# desktop-update.ps1 -- repo-owned Windows Desktop update hand-off. +# COMPAT FORWARDER — do not add logic here. # -# WHY THIS EXISTS (the frozen-binary problem): the Desktop's Update button -# used to hand off exclusively to the staged Tauri binary -# (%HERMES_HOME%\hermes-setup.exe). That binary has no self-update path -- -# copy_self_to_hermes_home deliberately no-ops during --update -- so every -# updater-side fix (cache refresh #67369, marker self-adopt #74782, straggler -# handling) only reaches users when a new installer is built, signed, and -# published. In practice binaries go months stale and users hit long-fixed -# bugs on every update (the 2026-08-09 incident chain). -# -# This script lives in the repo checkout, so EVERY `hermes update` refreshes -# the very code that drives the next update. The Desktop spawns it through a -# `cmd start` wrapper (see wrapHandoffForDetachedConsole in -# apps/desktop/electron/updater-process.ts -- a bare detached+hidden -# powershell dies before -File runs) and exits; only PowerShell itself -- an -# OS component -- is "frozen". -# -# CONTRACT (keep in sync with apps/desktop/electron/main.ts): -# cmd /d /s /c start "" /min powershell -NoProfile -ExecutionPolicy Bypass -# -File scripts\desktop-update.ps1 -# -InstallRoot repo checkout (HERMES_HOME\hermes-agent) -# -Branch branch to update against -# -DesktopPid the Electron main process to wait out -# [-RelaunchExe ] Hermes.exe to start when done (omit = no relaunch) -# [-NoUi] headless (tests); default shows a progress window -# [-NoMarkerCleanup] leave .hermes-update-in-progress in place (tests) -# -# SAFETY POSTURE: both preflight gates FAIL CLOSED. A Desktop that never -# exits, or a venv shim that never unlocks, aborts the hand-off without -# mutating the install -- a skipped update is recoverable, a half-updated -# venv is not. Every exit path (success, abort, crash) writes -# .hermes-update-result.json for the relaunched Desktop to surface, and -# relaunches the Desktop so the user is never left stranded. -# -# Marker: we claim HERMES_HOME\.hermes-update-in-progress with OUR pid as -# step 0 (the wrapper cmd.exe pid the Desktop saw is useless -- it exits -# immediately). hermes_cli/update_lock.py's ancestry rule lets our -# `hermes update` child adopt the claim; electron/update-marker.ts parks a -# relaunched Desktop on it. Cleanup only removes the marker while WE still -# own it (a handoff partner that rewrote it keeps its claim). - -param( - [Parameter(Mandatory = $true)][string]$InstallRoot, - [string]$Branch = "main", - [int]$DesktopPid = 0, - [string]$RelaunchExe = "", - [switch]$NoUi, - [switch]$NoMarkerCleanup -) - -$ErrorActionPreference = "Continue" -# Foreground helpers: the script is spawned via `cmd start /min`, so its -# WinForms window comes up backgrounded unless we explicitly claim focus -- -# and after the update we must hand focus TO the relaunched Desktop (a -# WMI-spawned process starts unfocused). AllowSetForegroundWindow lets us -# pass our foreground right on to the new Hermes.exe pid. -try { - Add-Type -Namespace HermesHandoff -Name Win32 -MemberDefinition @' -[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr hWnd); -[DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int dwProcessId); -[DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow); -'@ -ErrorAction Stop - $script:Win32 = $true -} catch { $script:Win32 = $false } -# Render UTF-8 glyphs (checkmarks, arrows) correctly in our own console echo -# too; the legacy conhost default OEM codepage shows them as mojibake. -try { - [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 - $OutputEncoding = [System.Text.Encoding]::UTF8 -} catch {} -$HermesHome = Split-Path -Parent $InstallRoot -$MarkerPath = Join-Path $HermesHome ".hermes-update-in-progress" -$LogDir = Join-Path $HermesHome "logs" -$LogPath = Join-Path $LogDir "desktop-update-handoff.log" -$ResultPath = Join-Path $HermesHome ".hermes-update-result.json" -$script:Ui = $null - -function Write-HandoffLog([string]$Message) { - $line = "{0:yyyy-MM-ddTHH:mm:ssK} {1}" -f (Get-Date), $Message - try { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } catch {} - Write-Host $line - if ($script:Ui) { - try { - $script:Ui.Box.AppendText($Message + "`r`n") - [System.Windows.Forms.Application]::DoEvents() - } catch {} - } -} - -function Show-ProgressWindow { - if ($NoUi) { return } - try { - Add-Type -AssemblyName System.Windows.Forms | Out-Null - Add-Type -AssemblyName System.Drawing | Out-Null - $form = New-Object System.Windows.Forms.Form - $form.Text = "Hermes Update" - $form.Size = New-Object System.Drawing.Size(720, 420) - $form.StartPosition = "CenterScreen" - $form.ControlBox = $false - $form.TopMost = $true - $label = New-Object System.Windows.Forms.Label - $label.Text = "Updating Hermes -- do not close this window. Hermes restarts automatically when the update finishes." - $label.Dock = "Top" - $label.Height = 34 - $label.Padding = New-Object System.Windows.Forms.Padding(8, 8, 8, 0) - $bar = New-Object System.Windows.Forms.ProgressBar - $bar.Style = "Marquee" - $bar.MarqueeAnimationSpeed = 30 - $bar.Dock = "Top" - $bar.Height = 18 - $box = New-Object System.Windows.Forms.TextBox - $box.Multiline = $true - $box.ReadOnly = $true - $box.ScrollBars = "Vertical" - $box.Dock = "Fill" - $box.Font = New-Object System.Drawing.Font("Consolas", 9) - $form.Controls.Add($box) - $form.Controls.Add($bar) - $form.Controls.Add($label) - $form.Show() - # `cmd start /min` spawned us backgrounded; TopMost keeps the window - # above others but does not take activation. Claim it explicitly so - # the progress window is what the user sees during the update. - try { - $form.Activate() - if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($form.Handle) | Out-Null } - } catch {} - [System.Windows.Forms.Application]::DoEvents() - $script:Ui = [pscustomobject]@{ Form = $form; Box = $box } - } catch { - # Headless session / WinForms unavailable: degrade to log-only. - $script:Ui = $null - } -} - -function Close-ProgressWindow { - if ($script:Ui) { - try { $script:Ui.Form.Close() } catch {} - $script:Ui = $null - } -} - -function Write-Result([bool]$Ok, [int]$Code, [string]$Message) { - # Consumed (read + deleted) by the relaunched Desktop on boot so the - # user actually SEES how a detached update ended. - try { - $obj = @{ - ok = $Ok - exit_code = $Code - message = $Message - branch = $Branch - finished_at = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture) - } | ConvertTo-Json -Compress - [System.IO.File]::WriteAllText($ResultPath, $obj) - } catch {} -} - -function Remove-MarkerIfOwned { - if ($NoMarkerCleanup) { return } - try { - if (Test-Path -LiteralPath $MarkerPath) { - $firstLine = (Get-Content -LiteralPath $MarkerPath -TotalCount 1 -ErrorAction SilentlyContinue) - if ("$firstLine".Trim() -eq "$PID") { - Remove-Item -LiteralPath $MarkerPath -Force -ErrorAction SilentlyContinue - Write-HandoffLog "removed update marker (owned)" - } else { - Write-HandoffLog "leaving update marker: owned by pid '$firstLine', not us ($PID)" - } - } - } catch {} -} - -function Start-DesktopRelaunch { - if ($RelaunchExe -and (Test-Path -LiteralPath $RelaunchExe)) { - Write-HandoffLog "relaunching desktop: $RelaunchExe" - # DO NOT spawn Hermes.exe as our child: Electron/Chromium calls - # AttachConsole(ATTACH_PARENT_PROCESS) at boot, so a Desktop launched - # directly from this console PowerShell latches onto OUR console -- - # the console window then outlives the script (it can't close while - # an attached process lives), and closing it kills the freshly - # relaunched GUI with it. Create the process via WMI instead: the - # parent becomes WmiPrvSE.exe and there is no console to inherit or - # attach -- same detachment explorer.exe gives a normal launch. - $spawned = $false - try { - $workDir = Split-Path -Parent $RelaunchExe - $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ - CommandLine = ('"{0}"' -f $RelaunchExe) - CurrentDirectory = $workDir - } -ErrorAction Stop - if ($r -and $r.ReturnValue -eq 0) { - Write-HandoffLog "desktop relaunched detached (pid $($r.ProcessId))" - $spawned = $true - # Hand our foreground rights to the new Desktop and focus its - # main window once it exists. A WMI-spawned process starts - # unfocused, and Windows only lets the CURRENT foreground - # owner (us, while the progress window is up / just closed) - # delegate that right. Poll briefly for the window: Electron - # takes a couple seconds to create it. - try { - if ($script:Win32) { - [HermesHandoff.Win32]::AllowSetForegroundWindow([int]$r.ProcessId) | Out-Null - $deadline = (Get-Date).AddSeconds(20) - while ((Get-Date) -lt $deadline) { - $hwnd = [System.IntPtr]::Zero - try { - $p = Get-Process -Id $r.ProcessId -ErrorAction Stop - $hwnd = $p.MainWindowHandle - } catch { break } # process died; nothing to focus - if ($hwnd -ne [System.IntPtr]::Zero) { - [HermesHandoff.Win32]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE - [HermesHandoff.Win32]::SetForegroundWindow($hwnd) | Out-Null - Write-HandoffLog "focused relaunched desktop window" - break - } - Start-Sleep -Milliseconds 400 - } - } - } catch { - Write-HandoffLog "WARNING: could not focus relaunched desktop: $($_.Exception.Message)" - } - } else { - Write-HandoffLog "WARNING: WMI relaunch returned $($r.ReturnValue); falling back" - } - } catch { - Write-HandoffLog "WARNING: WMI relaunch failed: $($_.Exception.Message); falling back" - } - if (-not $spawned) { - try { - # Fallback keeps the old behavior (console tie-in and all) -- - # a tethered Desktop beats no Desktop. - Start-Process -FilePath $RelaunchExe -WorkingDirectory (Split-Path -Parent $RelaunchExe) | Out-Null - } catch { - Write-HandoffLog "WARNING: desktop relaunch failed: $($_.Exception.Message)" - } - } - } -} - -function Invoke-StreamedHermes([string]$Exe, [string[]]$HermesArgs, [string]$Tag) { - # Start-Process + output file + poll keeps the WinForms window pumping - # during long silent stretches (pip installs); a blocking pipeline would - # freeze the marquee. Returns @{ Code; Output }. - $outFile = Join-Path $env:TEMP ("hermes-handoff-{0}-{1}.out" -f $Tag, $PID) - $errFile = Join-Path $env:TEMP ("hermes-handoff-{0}-{1}.err" -f $Tag, $PID) - Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue - # System.Diagnostics.Process directly: Start-Process's .ExitCode is - # unreliably $null under PS 5.1 even with the Handle-touch workaround. - $psi = New-Object System.Diagnostics.ProcessStartInfo - $psi.FileName = $Exe - # .Arguments string (PS 5.1 / .NET Framework has no ArgumentList). - # Args here are fixed flags + a branch ref; quote each defensively. - $psi.Arguments = ($HermesArgs | ForEach-Object { '"{0}"' -f ($_ -replace '"', '\"') }) -join ' ' - $psi.UseShellExecute = $false - $psi.RedirectStandardOutput = $true - $psi.RedirectStandardError = $true - # hermes update prints UTF-8 (checkmarks, arrows, box glyphs). PS 5.1 - # defaults these readers to the OEM codepage, which mangles every - # multi-byte glyph into mojibake in the console AND the progress box. - $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 - $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 - # And ask the child to actually EMIT UTF-8: Python decides its stdio - # encoding from the console codepage when attached to one. - $psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8" - $psi.EnvironmentVariables["PYTHONUTF8"] = "1" - $psi.CreateNoWindow = $true - $proc = [System.Diagnostics.Process]::Start($psi) - $outWriter = [System.IO.File]::CreateText($outFile) - $errWriter = [System.IO.File]::CreateText($errFile) - # Pump synchronously in small reads so the UI stays alive; stderr is - # drained at the end (hermes update is stdout-dominant). - while (-not $proc.HasExited) { - while (-not $proc.StandardOutput.EndOfStream) { - $ln = $proc.StandardOutput.ReadLine() - if ($null -ne $ln) { - $outWriter.WriteLine($ln) - if ($ln.Trim()) { Write-HandoffLog ("{0}| {1}" -f $Tag, $ln) } - } - if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } - } - Start-Sleep -Milliseconds 150 - if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } - } - while (-not $proc.StandardOutput.EndOfStream) { - $ln = $proc.StandardOutput.ReadLine() - if ($null -ne $ln) { - $outWriter.WriteLine($ln) - if ($ln.Trim()) { Write-HandoffLog ("{0}| {1}" -f $Tag, $ln) } - } - } - $errText = $proc.StandardError.ReadToEnd() - if ($errText) { - $errWriter.Write($errText) - foreach ($ln in ($errText -split "`r?`n")) { - if ($ln.Trim()) { Write-HandoffLog ("{0}!| {1}" -f $Tag, $ln) } - } - } - $outWriter.Close(); $errWriter.Close() - $proc.WaitForExit() - $code = $proc.ExitCode - $all = "" - try { $all = [System.IO.File]::ReadAllText($outFile) } catch {} - if ($errText) { $all += "`n" + $errText } - Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue - return @{ Code = $code; Output = $all } -} - -$finalCode = 1 -$finalMsg = "update did not complete" -try { - New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null - Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue - Show-ProgressWindow - Write-HandoffLog "hand-off start: root=$InstallRoot branch=$Branch desktopPid=$DesktopPid pid=$PID" - - # -- 0. Claim the update marker with OUR pid --------------------------- - try { - $epoch = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture) - # WriteAllText for byte-exact LF framing: Set-Content emits CRLF and - # the marker contract (Rust/TS/Python readers) is "\n\n". - [System.IO.File]::WriteAllText($MarkerPath, "$PID`n$epoch`n") - Write-HandoffLog "claimed update marker (pid $PID)" - } catch { - Write-HandoffLog "WARNING: could not write update marker: $($_.Exception.Message)" - } - - # -- 1. Wait for the Desktop to exit (FAIL CLOSED) ---------------------- - if ($DesktopPid -gt 0) { - $deadline = (Get-Date).AddSeconds(30) - while ((Get-Date) -lt $deadline) { - $proc = Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue - if (-not $proc) { break } - Start-Sleep -Milliseconds 300 - if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } - } - if (Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue) { - # A live Desktop means a live backend re-locking the venv at any - # moment. Updating under it is how installs brick. Abort. - $finalCode = 4 - $finalMsg = "Update aborted: the Hermes window (pid $DesktopPid) did not exit within 30s. Nothing was changed. Close Hermes fully and try again." - Write-HandoffLog $finalMsg - exit $finalCode - } - Write-HandoffLog "desktop exited" - } - - # -- 2. Wait for the venv shim to unlock (FAIL CLOSED) ------------------ - $shim = Join-Path $InstallRoot "venv\Scripts\hermes.exe" - if (Test-Path -LiteralPath $shim) { - $unlocked = $false - $deadline = (Get-Date).AddSeconds(20) - while ((Get-Date) -lt $deadline) { - try { - $fs = [System.IO.File]::Open($shim, 'Open', 'ReadWrite', 'None') - $fs.Close() - $unlocked = $true - break - } catch { - Start-Sleep -Milliseconds 400 - if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } - } - } - if (-not $unlocked) { - # Something still maps the venv. --force-ing past it guarantees a - # half-updated venv (the exact 2026-08-09 Access-denied brick). - $finalCode = 5 - $finalMsg = "Update aborted: another process is still holding the Hermes install open (venv\Scripts\hermes.exe locked after 20s). Nothing was changed. Close other Hermes windows/terminals and try again." - Write-HandoffLog $finalMsg - exit $finalCode - } - Write-HandoffLog "venv shim unlocked" - } - - # -- 3. Run the update from the CURRENT checkout ------------------------ - # --force skips only the hermes.exe shim guard, which step 2 just PROVED - # is unlocked; the venv-python holder guard (orphan reap included) stays - # active. Our marker claim is adopted by the child via update_lock.py's - # process-ancestry rule. - $hermesExe = Join-Path $InstallRoot "venv\Scripts\hermes.exe" - if (-not (Test-Path -LiteralPath $hermesExe)) { - $finalCode = 3 - $finalMsg = "Update aborted: $hermesExe is missing. The install needs repair (run the Hermes installer or `hermes doctor`)." - Write-HandoffLog $finalMsg - exit $finalCode - } - $updateArgs = @("update", "--yes", "--gateway", "--force", "--branch", $Branch) - Write-HandoffLog ("running: hermes " + ($updateArgs -join " ")) - $res = Invoke-StreamedHermes $hermesExe $updateArgs "update" - Write-HandoffLog "hermes update exit code: $($res.Code)" - - if ($res.Code -ne 0 -and $res.Code -ne 2) { - # One retry for the update-boundary class (fresh code on disk, stale - # code in memory). Exit 2 ("close all Hermes windows") is not retryable. - Write-HandoffLog "first attempt failed; retrying once (freshly pulled fix loads on the second run)" - $res = Invoke-StreamedHermes $hermesExe $updateArgs "update" - Write-HandoffLog "retry exit code: $($res.Code)" - } - - # -- 4. Truthful completion: don't trust exit 0 ------------------------- - # `hermes update` treats a Desktop GUI build failure as NON-fatal (prints - # a one-line warning, exits 0). For a Desktop-DRIVEN update that warning - # is fatal: we would relaunch the old exe and call it success. Detect it, - # retry the build once, and propagate honestly. - $desktopBuildFailed = $false - if ($res.Code -eq 0 -and $res.Output -match "Desktop build failed") { - Write-HandoffLog "hermes update reported a desktop build failure (non-fatal there, fatal here); retrying build" - $rebuild = Invoke-StreamedHermes $hermesExe @("desktop", "--force-build", "--build-only") "rebuild" - Write-HandoffLog "desktop rebuild exit code: $($rebuild.Code)" - if ($rebuild.Code -ne 0) { $desktopBuildFailed = $true } - } - - if ($res.Code -eq 0 -and -not $desktopBuildFailed) { - $finalCode = 0 - $finalMsg = "Update complete." - } elseif ($desktopBuildFailed) { - $finalCode = 6 - $finalMsg = "Code and dependencies updated, but the Desktop app REBUILD FAILED - you are running the previous build. Run `hermes desktop --force-build` from a terminal to retry." - } else { - $finalCode = $res.Code - $finalMsg = "hermes update failed (exit $($res.Code)). See logs\desktop-update-handoff.log." - } - exit $finalCode -} finally { - Write-Result ($finalCode -eq 0) $finalCode $finalMsg - Remove-MarkerIfOwned - Close-ProgressWindow - Start-DesktopRelaunch -} +# The hand-off moved to scripts/desktop-update/windows.ps1. This forwarder +# exists for exactly one consumer: an already-installed Desktop whose asar +# is one update behind and still spawns scripts/desktop-update.ps1 (see +# resolveUpdateScriptHandoff in apps/desktop/electron/updater-process.ts). +# Without it, that Desktop would silently fall back to the frozen staged +# Tauri binary for one update cycle — the exact rot this script family +# exists to escape. +& (Join-Path $PSScriptRoot "desktop-update\windows.ps1") @args +exit $LASTEXITCODE diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh new file mode 100755 index 0000000000000..80d2715dd82df --- /dev/null +++ b/scripts/desktop-update/posix.sh @@ -0,0 +1,428 @@ +#!/bin/bash +# posix.sh -- repo-owned macOS/Linux Desktop update hand-off. +# +# The whole job: wait for the Desktop to exit, run `hermes update`, tell the +# shim how it went, reopen the app. The Desktop spawns this detached and +# quits; because it lives in the checkout, every update refreshes the code +# that drives the next one. Replaces the in-app updater +# (applyUpdatesPosixInApp) -- with the app gone before the update starts, +# the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance dies with it. +# +# CONTRACT (keep in sync with apps/desktop/electron/main.ts): +# bash scripts/desktop-update/posix.sh +# --install-root repo checkout (HERMES_HOME/hermes-agent) +# --branch branch to update against +# --desktop-pid the Electron main process to wait out +# [--relaunch-target

] mac: running .app to swap+reopen; +# linux: running binary (omit = no relaunch) +# [--relaunch-cwd

] linux: working directory to restore on relaunch +# [--sandbox-fallback] linux: the caller vouches for a sandbox opt-out +# (ELECTRON_DISABLE_SANDBOX / --no-sandbox launch) +# [--no-ui] [--no-marker-cleanup] [--self-test-ui] [--self-test-gate] +# [-- ] linux: filtered launch args to replay +# +# The shim (ui.html in a chromeless browser app window) is decoration: it +# polls /progress for `done` or `error` and reacts. It owns nothing -- +# relaunch, result file, marker hygiene all happen here, identically, when +# no renderer exists. No chromium-family browser found = no UI, fine. +# +# ORDERING (the durable-truth rule): swap and relaunch are DECIDED AND +# EXECUTED before the result file is written, the marker is removed, or a +# terminal event reaches the shim. Nothing user-visible may claim an outcome +# the filesystem hasn't already delivered. + +set -u + +INSTALL_ROOT="" BRANCH="main" DESKTOP_PID=0 RELAUNCH_TARGET="" +RELAUNCH_CWD="" SANDBOX_FALLBACK=0 RELAUNCH_ARGS=() +NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0 SELF_TEST_GATE=0 +while [ $# -gt 0 ]; do + case "$1" in + --install-root) INSTALL_ROOT="$2"; shift 2 ;; + --branch) BRANCH="$2"; shift 2 ;; + --desktop-pid) DESKTOP_PID="$2"; shift 2 ;; + --relaunch-target) RELAUNCH_TARGET="$2"; shift 2 ;; + --relaunch-cwd) RELAUNCH_CWD="$2"; shift 2 ;; + --sandbox-fallback) SANDBOX_FALLBACK=1; shift ;; + --no-ui) NO_UI=1; shift ;; + --no-marker-cleanup) NO_MARKER_CLEANUP=1; shift ;; + --self-test-ui) SELF_TEST_UI=1; shift ;; + --self-test-gate) SELF_TEST_GATE=1; shift ;; + --) shift; RELAUNCH_ARGS=("$@"); shift $# ;; + *) echo "unknown arg: $1" >&2; exit 64 ;; + esac +done +[ "$SELF_TEST_UI" -eq 1 ] || [ -n "$INSTALL_ROOT" ] || { echo "--install-root is required" >&2; exit 64; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HERMES_HOME="${INSTALL_ROOT:+$(dirname "$INSTALL_ROOT")}" +HERMES_HOME="${HERMES_HOME:-${TMPDIR:-/tmp}}" +MARKER="$HERMES_HOME/.hermes-update-in-progress" +LOG_DIR="$HERMES_HOME/logs"; mkdir -p "$LOG_DIR" 2>/dev/null || true +LOG="$LOG_DIR/desktop-update-handoff.log" +RESULT="$HERMES_HOME/.hermes-update-result.json" +STATUS="${TMPDIR:-/tmp}/hermes-update-status.$$" + +UI_SERVER_PID="" UI_BROWSER_PID="" FINAL_CODE=1 +FINAL_MSG="update did not complete" +DONE_NOTE="" # set when the update succeeded but the app will NOT reopen itself + +log() { echo "$(date +%Y-%m-%dT%H:%M:%S%z) $1" | tee -a "$LOG" 2>/dev/null; } + +# ── shim ──────────────────────────────────────────────────────────────────── +json_escape() { # minimal JSON string escape: \ " and control whitespace + local s=${1//\\/\\\\} + s=${s//\"/\\\"} + s=${s//$'\n'/\\n} + s=${s//$'\r'/\\r} + s=${s//$'\t'/\\t} + printf '%s' "$s" +} + +notify_fallback() { # status message — renderer-free recovery surface. + # Fires only when there is no shim window. BEST-EFFORT immediate channel: + # each rung requires EXECUTION acceptance, not existence — notify-send's + # exit code is its acceptance (fire-and-forget), zenity/kdialog must + # survive their first second (a dialog that dies instantly had no display + # and must not eat the message). The GUARANTEED channel is the result + # file: a manual/error outcome is durably marked and the next Desktop + # boot surfaces it in a dialog (handoff-result.ts + main.ts). + case "$1" in manual|error) ;; *) return 0 ;; esac + if [ "$(uname)" = "Darwin" ]; then + /usr/bin/osascript -e "display notification \"$(printf '%s' "$2" | sed 's/"/\\"/g')\" with title \"Hermes update\"" 2>/dev/null && return 0 + else + if command -v notify-send >/dev/null 2>&1; then + notify-send -u critical "Hermes update" "$2" 2>/dev/null && return 0 + fi + local p + if command -v zenity >/dev/null 2>&1; then + zenity --warning --title="Hermes update" --text="$2" 2>/dev/null & + p=$!; sleep 1 + kill -0 "$p" 2>/dev/null && return 0 + wait "$p" 2>/dev/null + fi + if command -v kdialog >/dev/null 2>&1; then + kdialog --title "Hermes update" --sorry "$2" 2>/dev/null & + p=$!; sleep 1 + kill -0 "$p" 2>/dev/null && return 0 + wait "$p" 2>/dev/null + fi + fi + # No immediate surface landed. The durable channel takes over: the result + # is marked manual/failed and the next boot shows it in a real dialog. + log "NOTICE: no notification surface accepted; outcome reaches the user via the result dialog on next launch: $2" +} + +publish() { # status message -- atomic replace; the server reads per poll + printf '{"status":"%s","message":"%s"}' "$(json_escape "$1")" "$(json_escape "$2")" > "$STATUS.tmp" \ + && mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true + [ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state + [ -z "$UI_SERVER_PID" ] && notify_fallback "$1" "$2" +} + +find_browser() { + local c + if [ "$(uname)" = "Darwin" ]; then + for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium" \ + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"; do + [ -x "$c" ] && { echo "$c"; return; } + done + else + for c in google-chrome google-chrome-stable chromium chromium-browser microsoft-edge brave-browser; do + command -v "$c" 2>/dev/null && return + done + fi +} + +start_ui() { + [ "$NO_UI" -eq 1 ] && return + local html="$SCRIPT_DIR/ui.html" py browser port="" i + py="${INSTALL_ROOT:+$INSTALL_ROOT/venv/bin/python3}" + [ -x "${py:-/nonexistent}" ] || py="$(command -v python3 2>/dev/null)" + browser="$(find_browser)" + { [ -f "$html" ] && [ -n "$py" ] && [ -n "$browser" ]; } || { log "shim: no renderer; skipping UI"; return; } + + publish "running" "" + "$py" "$SCRIPT_DIR/serve-ui.py" "$html" "$STATUS" > "$LOG_DIR/desktop-update-ui-port" 2>>"$LOG" & + UI_SERVER_PID=$! + for i in $(seq 1 10); do + port="$(tr -cd '0-9' < "$LOG_DIR/desktop-update-ui-port" 2>/dev/null)" + [ -n "$port" ] && break + sleep 0.2 + done + [ -n "$port" ] || { kill "$UI_SERVER_PID" 2>/dev/null; UI_SERVER_PID=""; return; } + + # Throwaway profile: new window/process we own; user's browser untouched. + "$browser" --app="http://127.0.0.1:$port/" --user-data-dir="${TMPDIR:-/tmp}/hermes-update-ui-$$" \ + --no-first-run --no-default-browser-check --window-size=280,320 >/dev/null 2>&1 & + UI_BROWSER_PID=$! + log "shim: app window on 127.0.0.1:$port" +} + +stop_ui() { # error state leaves the window up for the user to read + if [ -n "$UI_SERVER_PID" ]; then + { kill "$UI_SERVER_PID" && wait "$UI_SERVER_PID"; } 2>/dev/null + fi + if [ "${1:-}" != "leave-window" ] && [ -n "$UI_BROWSER_PID" ]; then + { kill "$UI_BROWSER_PID" && wait "$UI_BROWSER_PID"; } 2>/dev/null + fi + UI_SERVER_PID="" UI_BROWSER_PID="" +} + +# ── relaunch ──────────────────────────────────────────────────────────────── +# Linux relaunch gate -- an exact port of the deleted update-relaunch.ts +# decision (#45205/#37541), not a loosened rewrite: +# * the running binary must live under THIS checkout's rebuilt +# apps/desktop/release/linux-unpacked (anchored, path-segment-aware -- +# proof the update we just ran replaced the selected executable); +# * chrome-sandbox ABSENT is fine (namespace-sandbox build; nothing to +# block on), PRESENT means root-owned AND setuid or Electron refuses to +# boot ("quit and never came back"); +# * a user sandbox opt-out (ELECTRON_DISABLE_SANDBOX=1/true in our +# inherited env, --no-sandbox among the replayed launch args, or the +# Desktop vouching via --sandbox-fallback) makes the relaunch safe +# despite a failed preflight. +# Outcomes mirror decideRelaunchOutcome: relaunch | skew | manual. +GATE="" GATE_MSG="" +linux_gate() { + local unpacked="$INSTALL_ROOT/apps/desktop/release/linux-unpacked" sb arg + case "$RELAUNCH_TARGET" in + "$unpacked"/*) ;; + *) GATE=skew GATE_MSG="Backend updated, but the desktop app package (AppImage/deb/rpm) was not changed. Update or reinstall it to match."; return ;; + esac + + sb="$unpacked/chrome-sandbox" + if [ ! -e "$sb" ]; then GATE=relaunch; return; fi + if [ -u "$sb" ] && [ "$(stat -c %u "$sb" 2>/dev/null)" = "0" ]; then GATE=relaunch; return; fi + + case "${ELECTRON_DISABLE_SANDBOX:-}" in 1|true|TRUE|True) GATE=relaunch; return ;; esac + [ "$SANDBOX_FALLBACK" -eq 1 ] && { GATE=relaunch; return; } + for arg in ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"}; do + [ "$arg" = "--no-sandbox" ] && { GATE=relaunch; return; } + done + + GATE=manual GATE_MSG="Update complete, but the rebuilt app can't relaunch itself (its sandbox helper needs root ownership). Reopen Hermes to finish." +} + +mac_swap() { + local rebuilt="" c + for c in "$INSTALL_ROOT/apps/desktop/release/mac-arm64/Hermes.app" \ + "$INSTALL_ROOT/apps/desktop/release/mac/Hermes.app"; do + [ -d "$c" ] && { rebuilt="$c"; break; } + done + + # Transactional swap: stage a full copy, move the old bundle aside, move + # the copy in. Every step checked; a failed final move ROLLS BACK so the + # user always has a launchable app, and the result file tells the truth. + if [ "$FINAL_CODE" -eq 0 ] && [ -n "$rebuilt" ] && [ -d "$RELAUNCH_TARGET" ] && [ "$rebuilt" != "$RELAUNCH_TARGET" ]; then + rm -rf "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET.old" 2>/dev/null || true + if ! /usr/bin/ditto "$rebuilt" "$RELAUNCH_TARGET.new"; then + rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true + DONE_NOTE="Update complete, but the new app could not be staged; the previous version was kept. Run the update again." + log "WARNING: bundle copy failed; keeping existing app" + elif ! mv "$RELAUNCH_TARGET" "$RELAUNCH_TARGET.old"; then + rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true + DONE_NOTE="Update complete, but the new app could not replace the old one; the previous version was kept. Run the update again." + log "WARNING: could not move old bundle aside; keeping existing app" + elif ! mv "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET"; then + if mv "$RELAUNCH_TARGET.old" "$RELAUNCH_TARGET"; then + rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true + DONE_NOTE="Update complete, but the new app could not be installed; the previous version was restored. Run the update again." + log "WARNING: bundle install failed; rolled back to the previous app" + else + FINAL_CODE=7 FINAL_MSG="The update finished but installing the new app failed and the previous app could not be restored. Reinstall Hermes (the rebuilt app is at $rebuilt)." + log "ERROR: bundle install failed AND rollback failed" + fi + else + rm -rf "$RELAUNCH_TARGET.old" 2>/dev/null || true + log "swapped app bundle" + fi + fi +} + +deliver_outcome() { # the truth-determining half: swap bundles / gate the relaunch + [ -n "$RELAUNCH_TARGET" ] || return 0 + if [ "$(uname)" = "Darwin" ]; then + mac_swap + else + linux_gate + if [ "$GATE" != "relaunch" ] && [ "$FINAL_CODE" -eq 0 ]; then + DONE_NOTE="$GATE_MSG" + log "no relaunch ($GATE): $GATE_MSG" + fi + fi +} + +launch_app() { # attempted BEFORE the terminal event (launch acceptance is + # part of the outcome — gille's review). Returns nonzero when a launch + # was due but did not verifiably happen; caller downgrades to manual. + [ -n "$RELAUNCH_TARGET" ] || return 0 + if [ "$(uname)" = "Darwin" ]; then + # A supplied target that no longer exists is a REJECTED launch (the + # swap failed badly or the bundle vanished) — not "no launch due". + [ -d "$RELAUNCH_TARGET" ] || { log "WARNING: relaunch target missing: $RELAUNCH_TARGET"; return 1; } + /usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true + # `open` talks to launchd and FAILS LOUDLY on a broken/unlaunchable + # bundle — its exit code IS launch acceptance here. + /usr/bin/open "$RELAUNCH_TARGET" || { log "WARNING: open rejected the app"; return 1; } + elif [ "$GATE" = "relaunch" ]; then + # setsid only proves the wrapper shell started, so verify acceptance: + # spawn, then confirm the child is still alive shortly after — an + # immediate exec failure (ENOENT, ELF mismatch, dead sandbox) dies + # within the window and downgrades to manual instead of lying. + (cd "${RELAUNCH_CWD:-/}" 2>/dev/null || cd / + setsid "$RELAUNCH_TARGET" ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"} >/dev/null 2>&1 & + echo $! > "$STATUS.launchpid") || { log "WARNING: relaunch spawn failed"; return 1; } + local lp + lp="$(cat "$STATUS.launchpid" 2>/dev/null)"; rm -f "$STATUS.launchpid" 2>/dev/null + [ -n "$lp" ] || { log "WARNING: relaunch pid unknown"; return 1; } + sleep 1.5 + kill -0 "$lp" 2>/dev/null || { log "WARNING: relaunched app exited immediately"; return 1; } + fi +} + +MANUAL=0 # 1 = update landed but the user must act (result protocol field) + +write_result() { + printf '{"ok":%s,"exit_code":%s,"manual":%s,"message":"%s","branch":"%s","finished_at":%s}' \ + "$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" \ + "$([ "$MANUAL" -eq 1 ] && echo true || echo false)" \ + "$(json_escape "$FINAL_MSG")" "$(json_escape "$BRANCH")" "$(date +%s)" \ + > "$RESULT.tmp" 2>/dev/null && mv -f "$RESULT.tmp" "$RESULT" 2>/dev/null || true +} + +finish() { + # Ordering (gille's reviews, both rounds): + # 1. deliver the outcome (swap/gate) so the truth exists; + # 2. durable result + marker removal (the relaunched app consumes the + # result on boot and must not park on our marker — this must be on + # disk BEFORE any launch attempt); + # 3. attempt the launch and require ACCEPTANCE; + # 4. only then the terminal shim event — done means "the app is coming + # back", manual means "it is not, here's what to do", error is error. + # A rejected launch rewrites the result (nothing consumed it — the app + # never started) so the next boot tells the truth too. + deliver_outcome + [ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && { FINAL_MSG="$DONE_NOTE"; MANUAL=1; } + write_result + + if [ "$NO_MARKER_CLEANUP" -eq 0 ] && [ "$(head -1 "$MARKER" 2>/dev/null | tr -d '[:space:]')" = "$$" ]; then + rm -f "$MARKER" 2>/dev/null || true + fi + + if [ "$FINAL_CODE" -ne 0 ]; then + publish "error" "$FINAL_MSG"; stop_ui leave-window + launch_app || true # error path still tries to bring the app back + rm -f "$STATUS" "$STATUS.tmp" "$LOG_DIR/desktop-update-ui-port" 2>/dev/null || true + return + fi + + if [ -n "$DONE_NOTE" ]; then + if [ "$(uname)" = "Darwin" ]; then + # mac DONE_NOTE = swap failed but the PREVIOUS bundle was kept/rolled + # back — bring it back up; the note still tells the user to re-run. + # A gated linux outcome (skew/manual) skips the launch BY DESIGN. + if ! launch_app; then + # Even the kept bundle didn't come back: the durable message must + # carry BOTH facts (update ok, previous app not reopened). + FINAL_MSG="$DONE_NOTE Hermes also could not reopen itself - open it manually." + write_result + fi + fi + publish "manual" "$FINAL_MSG"; stop_ui leave-window + elif launch_app; then + publish "done" ""; stop_ui + else + # Launch was due and did not land. Downgrade: truthful result for the + # next boot, manual state held on screen now. + FINAL_MSG="Update complete. Reopen Hermes to finish (it could not restart itself)." + MANUAL=1 + write_result + publish "manual" "$FINAL_MSG"; stop_ui leave-window + fi + rm -f "$STATUS" "$STATUS.tmp" "$LOG_DIR/desktop-update-ui-port" 2>/dev/null || true +} +trap finish EXIT + +# ── self-tests: no update, touch nothing ──────────────────────────────────── +if [ "$SELF_TEST_GATE" -eq 1 ]; then + # Prints the gate decision for the given --install-root/--relaunch-target + # and exits; scripts/desktop-update/repro.sh gate asserts the matrix. + trap - EXIT + linux_gate + echo "$GATE${GATE_MSG:+:$GATE_MSG}" + exit 0 +fi + +if [ "$SELF_TEST_UI" -eq 1 ]; then + start_ui + log "SELF-TEST: shim simulation (no update will run)" + sleep "${HERMES_SELFTEST_HOLD_SECONDS:-6}" + RELAUNCH_TARGET="" + if [ -n "${HERMES_SELFTEST_FAIL:-}" ]; then FINAL_MSG="self-test error state" + else FINAL_CODE=0 FINAL_MSG="self-test complete"; fi + exit "$FINAL_CODE" +fi + +# ── the actual job ────────────────────────────────────────────────────────── +log "hand-off start: root=$INSTALL_ROOT branch=$BRANCH desktopPid=$DESKTOP_PID pid=$$" +rm -f "$RESULT" 2>/dev/null || true +start_ui + +# Marker claim: same cross-process lock contract as windows.ps1 / +# update_lock.py (the `hermes update` child adopts it via process ancestry). +printf '%s\n%s\n' "$$" "$(date +%s)" > "$MARKER" 2>/dev/null || log "WARNING: could not write update marker" + +# Wait out the Desktop (FAIL CLOSED: updating under live backends bricks). +if [ "$DESKTOP_PID" -gt 0 ] 2>/dev/null; then + for _ in $(seq 1 100); do kill -0 "$DESKTOP_PID" 2>/dev/null || break; sleep 0.3; done + if kill -0 "$DESKTOP_PID" 2>/dev/null; then + FINAL_CODE=4 FINAL_MSG="Update aborted: the Hermes window (pid $DESKTOP_PID) did not exit within 30s. Nothing was changed. Close Hermes fully and try again." + log "$FINAL_MSG"; exit "$FINAL_CODE" + fi +fi + +HERMES_BIN="$INSTALL_ROOT/venv/bin/hermes" +[ -x "$HERMES_BIN" ] || { FINAL_CODE=3 FINAL_MSG="Update aborted: $HERMES_BIN is missing. The install needs repair (run the Hermes installer or hermes doctor)."; log "$FINAL_MSG"; exit 3; } + +# Run FROM the install root: `hermes update` resolves the tree it mutates +# from the working directory, and we inherit the Desktop's cwd (which can be +# an unrelated repo — updating THAT instead of the install is the failure +# the sandbox repro caught). FAIL CLOSED: set -u without set -e means a +# failed cd would otherwise continue in the wrong tree — the exact class +# this correction exists to eliminate. +cd "$INSTALL_ROOT" || { + FINAL_CODE=3 FINAL_MSG="Update aborted: cannot enter the install root ($INSTALL_ROOT). Nothing was changed." + log "$FINAL_MSG"; exit 3 +} +export PYTHONUNBUFFERED=1 +log "running: hermes update --yes --gateway --branch $BRANCH" +OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$? +printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null +log "hermes update exit code: $CODE" + +if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then + # Retry once: update-boundary class (fresh code on disk, stale in memory). + # Exit 2 ("close all Hermes windows") is not retryable. + log "retrying once (freshly pulled fix loads on the second run)" + OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$? + printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null + log "retry exit code: $CODE" +fi + +# Truthful completion: `hermes update` calls a GUI build failure non-fatal +# (exit 0). For a Desktop-driven update that would relaunch the OLD build +# and call it success -- retry the build once, propagate honestly. +if [ "$CODE" -eq 0 ] && printf '%s' "$OUT" | grep -q "Desktop build failed"; then + log "desktop build failed inside hermes update; retrying build" + "$HERMES_BIN" desktop --force-build --build-only >> "$LOG" 2>&1 || { + FINAL_CODE=6 FINAL_MSG="Code and dependencies updated, but the Desktop app rebuild failed - you are running the previous build. Run hermes desktop --force-build from a terminal to retry." + exit 6 + } +fi + +if [ "$CODE" -eq 0 ]; then FINAL_CODE=0 FINAL_MSG="Update complete." +else FINAL_CODE="$CODE" FINAL_MSG="Update failed (exit $CODE). Run hermes debug share in a terminal to send a report."; fi +exit "$FINAL_CODE" diff --git a/scripts/desktop-update/repro.sh b/scripts/desktop-update/repro.sh new file mode 100755 index 0000000000000..f2d39a3bf8d09 --- /dev/null +++ b/scripts/desktop-update/repro.sh @@ -0,0 +1,187 @@ +#!/bin/bash +# repro.sh -- reproduce desktop-update paths against a sandboxed HERMES_HOME. +# +# Nothing here touches your real ~/.hermes or checkout. Each mode builds (or +# reuses) a disposable install under /tmp and drives the REAL code path -- +# the actual installer, the actual orchestrator, the actual `hermes update`. +# +# repro.sh shim shim UI only: success event after 6s +# repro.sh shim-fail shim UI only: error event after 6s +# repro.sh fresh fresh install into a sandbox HERMES_HOME +# (scripts/install.sh, the literal user path) +# repro.sh behind [N] sandbox install rewound N commits (default 25), +# then the posix orchestrator drives it forward -- +# the "user who hasn't updated in a while" path +# repro.sh error orchestrator against a broken install (missing +# venv) -- exercises abort + result-file + shim error +# repro.sh gate linux relaunch-gate decision matrix (anchoring, +# sandbox preflight, opt-out fallbacks) -- asserts +# every outcome without touching a real install +# +# The sandbox persists between runs (~/tmp is fine to nuke): fresh reuses +# nothing, behind/error reuse the last sandbox install when present because +# a from-scratch install is minutes. +# +# npm entry points (apps/desktop/package.json): +# npm run update:shim / update:shim:fail / update:repro:fresh / +# update:repro:behind [-- N] / update:repro:error + +set -euo pipefail + +MODE="${1:-help}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SANDBOX="${HERMES_UPDATE_REPRO_HOME:-/tmp/hermes-update-repro}" +SANDBOX_ROOT="$SANDBOX/hermes-agent" + +say() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } + +ensure_sandbox_install() { + if [ -x "$SANDBOX_ROOT/venv/bin/hermes" ]; then + say "reusing sandbox install at $SANDBOX_ROOT" + return + fi + say "fresh sandbox install into $SANDBOX (this takes a while)" + rm -rf "$SANDBOX" + mkdir -p "$SANDBOX" + # The literal user path: install.sh against a clone of THIS checkout, so + # the repro reproduces what you're about to ship, not origin/main. + git clone --quiet "$REPO_ROOT" "$SANDBOX_ROOT" + HERMES_HOME="$SANDBOX" bash "$SANDBOX_ROOT/scripts/install.sh" --non-interactive --skip-setup --hermes-home "$SANDBOX" +} + +case "$MODE" in + shim) + HERMES_SELFTEST_HOLD_SECONDS="${HERMES_SELFTEST_HOLD_SECONDS:-6}" \ + bash "$SCRIPT_DIR/posix.sh" --self-test-ui + ;; + shim-fail) + HERMES_SELFTEST_FAIL=1 HERMES_SELFTEST_HOLD_SECONDS="${HERMES_SELFTEST_HOLD_SECONDS:-6}" \ + bash "$SCRIPT_DIR/posix.sh" --self-test-ui + ;; + fresh) + rm -rf "$SANDBOX" + ensure_sandbox_install + say "fresh install OK: $("$SANDBOX_ROOT/venv/bin/hermes" --version 2>/dev/null || echo '?')" + ;; + behind) + N="${2:-25}" + ensure_sandbox_install + say "rewinding sandbox checkout $N commits" + git -C "$SANDBOX_ROOT" fetch --quiet origin main || true + git -C "$SANDBOX_ROOT" checkout --quiet main + git -C "$SANDBOX_ROOT" reset --hard --quiet "HEAD~$N" + say "sandbox now at: $(git -C "$SANDBOX_ROOT" log --oneline -1)" + say "driving the orchestrator (watch the shim; log: $SANDBOX/logs/desktop-update-handoff.log)" + HERMES_HOME="$SANDBOX" bash "$SCRIPT_DIR/posix.sh" \ + --install-root "$SANDBOX_ROOT" --branch main --desktop-pid 0 || true + say "result file:" + cat "$SANDBOX/.hermes-update-result.json" 2>/dev/null || echo "(none written)" + echo + say "sandbox after update: $(git -C "$SANDBOX_ROOT" log --oneline -1)" + ;; + error) + ensure_sandbox_install + say "breaking the sandbox venv, then driving the orchestrator" + mv "$SANDBOX_ROOT/venv" "$SANDBOX_ROOT/venv.hidden" + HERMES_HOME="$SANDBOX" bash "$SCRIPT_DIR/posix.sh" \ + --install-root "$SANDBOX_ROOT" --branch main --desktop-pid 0 || true + mv "$SANDBOX_ROOT/venv.hidden" "$SANDBOX_ROOT/venv" + say "result file (expect ok:false, exit 3):" + cat "$SANDBOX/.hermes-update-result.json" 2>/dev/null || echo "(none written)" + echo + ;; + gate) + # Pure-decision matrix for the linux relaunch gate. Builds a fake + # checkout layout under /tmp; --self-test-gate prints the decision and + # exits without running an update. + G="/tmp/hermes-gate-test.$$" + UNPACKED="$G/hermes-agent/apps/desktop/release/linux-unpacked" + mkdir -p "$UNPACKED" + touch "$UNPACKED/hermes" && chmod +x "$UNPACKED/hermes" + + fails=0 + expect() { # name expected actual + if [ "$2" = "$3" ]; then printf 'ok %s -> %s\n' "$1" "$3" + else printf 'FAIL %s -> %s (want %s)\n' "$1" "$3" "$2"; fails=$((fails+1)); fi + } + decide() { bash "$SCRIPT_DIR/posix.sh" --self-test-gate --install-root "$G/hermes-agent" "$@" | cut -d: -f1; } + + expect "appimage (not under unpacked)" skew "$(decide --relaunch-target /opt/Hermes/hermes)" + expect "sibling-prefix dir not fooled" skew "$(decide --relaunch-target "$UNPACKED-evil/hermes")" + expect "no chrome-sandbox (namespace)" relaunch "$(decide --relaunch-target "$UNPACKED/hermes")" + + touch "$UNPACKED/chrome-sandbox" + expect "sandbox not root/setuid" manual "$(decide --relaunch-target "$UNPACKED/hermes")" + expect "opt-out: --sandbox-fallback" relaunch "$(decide --relaunch-target "$UNPACKED/hermes" --sandbox-fallback)" + expect "opt-out: --no-sandbox launch arg" relaunch "$(decide --relaunch-target "$UNPACKED/hermes" -- --no-sandbox)" + expect "opt-out: ELECTRON_DISABLE_SANDBOX" relaunch "$(ELECTRON_DISABLE_SANDBOX=1 decide --relaunch-target "$UNPACKED/hermes")" + + # Result JSON must survive hostile strings (git allows `"` in branch + # names; messages carry arbitrary text) -- parse it back with python. + QHOME="$G/qhome"; mkdir -p "$QHOME/hermes-agent" + bash "$SCRIPT_DIR/posix.sh" --no-ui --no-marker-cleanup --desktop-pid 0 \ + --install-root "$QHOME/hermes-agent" --branch 'evil"branch\n$(x)' >/dev/null 2>&1 || true + if python3 -c "import json,sys; d=json.load(open('$QHOME/.hermes-update-result.json')); sys.exit(0 if d['branch']=='evil\"branch\\\\n\$(x)' and d['ok']==False else 1)"; then + printf 'ok result JSON escapes hostile branch/message\n' + else + printf 'FAIL result JSON escaping\n'; fails=$((fails+1)) + fi + + rm -rf "$G" + [ "$fails" -eq 0 ] && say "gate matrix: all pass" || { say "gate matrix: $fails FAILED"; exit 1; } + ;; + launch) + # Terminal-lifecycle matrix (gille round 2): launch acceptance is part + # of the outcome. Each case runs the REAL orchestrator (--no-ui) against + # a fake install whose `hermes` stub exits 0 instantly, so the flow + # reaches finish() with FINAL_CODE=0 and exercises the launch leg. + L="/tmp/hermes-launch-test.$$" + fails=0 + expect_msg() { # name python-expr + if python3 -c "import json,sys; d=json.load(open('$L/.hermes-update-result.json')); sys.exit(0 if ($2) else 1)"; then + printf 'ok %s\n' "$1" + else + printf 'FAIL %s -> %s\n' "$1" "$(cat "$L/.hermes-update-result.json" 2>/dev/null)"; fails=$((fails+1)) + fi + } + stub_install() { # creates a fake install whose hermes update succeeds + rm -rf "$L"; mkdir -p "$L/hermes-agent/venv/bin" + printf '#!/bin/sh\nexit 0\n' > "$L/hermes-agent/venv/bin/hermes" + chmod +x "$L/hermes-agent/venv/bin/hermes" + } + + # 1. linux relaunch target dies instantly -> manual downgrade in result + stub_install + UNPACKED="$L/hermes-agent/apps/desktop/release/linux-unpacked" + mkdir -p "$UNPACKED" + printf '#!/bin/sh\nexit 1\n' > "$UNPACKED/hermes"; chmod +x "$UNPACKED/hermes" + if [ "$(uname)" != "Darwin" ]; then + bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \ + --relaunch-target "$UNPACKED/hermes" >/dev/null 2>&1 || true + expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']" + else + # mac: a SUPPLIED target that is missing is a REJECTED launch and + # must downgrade to manual — never a clean "Update complete." + bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \ + --relaunch-target "$L/NoSuch.app" >/dev/null 2>&1 || true + expect_msg "missing bundle downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']" + fi + + # 2. gated skew: success result carries the skew message (the manual + # event's payload), never a bare "Update complete." + stub_install + bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \ + --relaunch-target /opt/Hermes/hermes >/dev/null 2>&1 || true + if [ "$(uname)" != "Darwin" ]; then + expect_msg "skew outcome surfaces in result message" "d['ok']==True and d['manual']==True and 'was not changed' in d['message']" + fi + + rm -rf "$L" + [ "$fails" -eq 0 ] && say "launch matrix: all pass" || { say "launch matrix: $fails FAILED"; exit 1; } + ;; + *) + sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//' + exit 64 + ;; +esac diff --git a/scripts/desktop-update/serve-ui.py b/scripts/desktop-update/serve-ui.py new file mode 100644 index 0000000000000..8d0a6bfe61221 --- /dev/null +++ b/scripts/desktop-update/serve-ui.py @@ -0,0 +1,48 @@ +"""Loopback shim server for the desktop update hand-off. + +Two GET routes: / serves ui.html, /progress serves the status file the +orchestrator script writes ({"status": "running"|"done"|"error", ...}). +Exists because a file:// page cannot receive events from a detached +process. Prints the chosen ephemeral port on stdout, serves until killed. +""" + +import http.server +import json +import socketserver +import sys + +html_path, status_path = sys.argv[1], sys.argv[2] +with open(html_path, "rb") as f: + HTML = f.read() + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): # noqa: A002 - base class signature + pass + + def do_GET(self): + if self.path.startswith("/progress"): + try: + with open(status_path, "rb") as f: + body = f.read() + json.loads(body) + except Exception: + body = b'{"status":"running","message":""}' + ctype = "application/json; charset=utf-8" + elif self.path == "/": + body, ctype = HTML, "text/html; charset=utf-8" + else: + self.send_response(404) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + +with socketserver.TCPServer(("127.0.0.1", 0), Handler) as srv: + print(srv.server_address[1], flush=True) + srv.serve_forever() diff --git a/scripts/desktop-update/ui.html b/scripts/desktop-update/ui.html new file mode 100644 index 0000000000000..0009a98e529ce --- /dev/null +++ b/scripts/desktop-update/ui.html @@ -0,0 +1,245 @@ + + + + + +Hermes + + + +

+
+
+

Updating Hermes

+

Hermes will open once done.

+
+ + + diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 new file mode 100644 index 0000000000000..1c81bdf1146b4 --- /dev/null +++ b/scripts/desktop-update/windows.ps1 @@ -0,0 +1,744 @@ +# windows.ps1 -- repo-owned Windows Desktop update hand-off. +# +# WHY THIS EXISTS (the frozen-binary problem): the Desktop's Update button +# used to hand off exclusively to the staged Tauri binary +# (%HERMES_HOME%\hermes-setup.exe). That binary has no self-update path -- +# copy_self_to_hermes_home deliberately no-ops during --update -- so every +# updater-side fix (cache refresh #67369, marker self-adopt #74782, straggler +# handling) only reaches users when a new installer is built, signed, and +# published. In practice binaries go months stale and users hit long-fixed +# bugs on every update (the 2026-08-09 incident chain). +# +# This script lives in the repo checkout, so EVERY `hermes update` refreshes +# the very code that drives the next update. The Desktop spawns it through a +# `cmd start` wrapper (see wrapHandoffForDetachedConsole in +# apps/desktop/electron/updater-process.ts -- a bare detached+hidden +# powershell dies before -File runs) and exits; only PowerShell itself -- an +# OS component -- is "frozen". +# +# CONTRACT (keep in sync with apps/desktop/electron/main.ts): +# cmd /d /s /c start "" /min powershell -NoProfile -ExecutionPolicy Bypass +# -File scripts\desktop-update\windows.ps1 +# -InstallRoot repo checkout (HERMES_HOME\hermes-agent) +# -Branch branch to update against +# -DesktopPid the Electron main process to wait out +# [-RelaunchExe ] Hermes.exe to start when done (omit = no relaunch) +# [-NoUi] headless (tests); default shows a progress window +# [-NoMarkerCleanup] leave .hermes-update-in-progress in place (tests) +# +# SAFETY POSTURE: both preflight gates FAIL CLOSED. A Desktop that never +# exits, or a venv shim that never unlocks, aborts the hand-off without +# mutating the install -- a skipped update is recoverable, a half-updated +# venv is not. Every exit path (success, abort, crash) writes +# .hermes-update-result.json for the relaunched Desktop to surface, and +# relaunches the Desktop so the user is never left stranded. +# +# Marker: we claim HERMES_HOME\.hermes-update-in-progress with OUR pid as +# step 0 (the wrapper cmd.exe pid the Desktop saw is useless -- it exits +# immediately). hermes_cli/update_lock.py's ancestry rule lets our +# `hermes update` child adopt the claim; electron/update-marker.ts parks a +# relaunched Desktop on it. Cleanup only removes the marker while WE still +# own it (a handoff partner that rewrote it keeps its claim). + +param( + [string]$InstallRoot, + [string]$Branch = "main", + [int]$DesktopPid = 0, + [string]$RelaunchExe = "", + [switch]$NoUi, + [switch]$NoMarkerCleanup, + [switch]$SelfTestUi +) + +if (-not $SelfTestUi -and -not $InstallRoot) { + # Mandatory in spirit; relaxed in the signature only so -SelfTestUi can + # drive the UI without a checkout. + throw "-InstallRoot is required" +} + +$ErrorActionPreference = "Continue" +# Foreground helpers: the script is spawned via `cmd start /min`, so its +# WinForms window comes up backgrounded unless we explicitly claim focus -- +# and after the update we must hand focus TO the relaunched Desktop (a +# WMI-spawned process starts unfocused). AllowSetForegroundWindow lets us +# pass our foreground right on to the new Hermes.exe pid. +try { + Add-Type -Namespace HermesHandoff -Name Win32 -MemberDefinition @' +[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr hWnd); +[DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int dwProcessId); +[DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow); +'@ -ErrorAction Stop + $script:Win32 = $true +} catch { $script:Win32 = $false } +# Render UTF-8 glyphs (checkmarks, arrows) correctly in our own console echo +# too; the legacy conhost default OEM codepage shows them as mojibake. +try { + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 +} catch {} +$TempDir = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() } +$HermesHome = if ($InstallRoot) { Split-Path -Parent $InstallRoot } else { $TempDir } +$MarkerPath = Join-Path $HermesHome ".hermes-update-in-progress" +$LogDir = Join-Path $HermesHome "logs" +$LogPath = Join-Path $LogDir "desktop-update-handoff.log" +$ResultPath = Join-Path $HermesHome ".hermes-update-result.json" +$script:Ui = $null + +function Write-HandoffLog([string]$Message) { + $line = "{0:yyyy-MM-ddTHH:mm:ssK} {1}" -f (Get-Date), $Message + try { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } catch {} + Write-Host $line +} + +# ── The shim: repo-owned HTML in a chromeless Edge app window ────────────── +# The window is a veneer, not a participant: the update runs identically with +# or without it (Edge missing/failed degrades to the WinForms card below, +# then log-only). It streams nothing and knows nothing — it polls /progress +# for one of two events, `done` or `error`, and reacts. The loopback listener +# is not a web server in any meaningful sense; it exists because file:// pages +# cannot receive events from a detached process. Salvaged from the web-shell +# spike (Co-authored-by: teknium1), reshaped to the quiet update-surface +# contract (#75895/#83634): loader, one title, one line, no dashboard. +$script:UiState = [hashtable]::Synchronized(@{ + status = "running" # running | done | error + message = "" +}) +$script:UiServer = $null # @{ Listener; Runspace; PowerShell; Port; EdgeProc } + +function Get-UiHtmlPath { + # Lives next to this script in the checkout. Missing file = fall back to + # WinForms (old checkouts mid-update, partial syncs). + $p = Join-Path $PSScriptRoot "ui.html" + if (Test-Path -LiteralPath $p) { return $p } + return $null +} + +function Find-EdgeExe { + foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:LOCALAPPDATA)) { + if (-not $root) { continue } + $p = Join-Path $root "Microsoft\Edge\Application\msedge.exe" + if (Test-Path -LiteralPath $p) { return $p } + } + return $null +} + +function Start-UiServer([string]$HtmlPath) { + # In-process HTTP on a loopback ephemeral port, served from a dedicated + # runspace so the main thread never blocks on Accept. Plain TcpListener + # instead of HttpListener: no URL ACL / netsh reservation semantics to + # trip over, and two GET routes don't need more. + try { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + $port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + + $rs = [runspacefactory]::CreateRunspace() + $rs.Open() + $rs.SessionStateProxy.SetVariable("Listener", $listener) + $rs.SessionStateProxy.SetVariable("State", $script:UiState) + $rs.SessionStateProxy.SetVariable("HtmlBytes", [System.IO.File]::ReadAllBytes($HtmlPath)) + + $ps = [powershell]::Create() + $ps.Runspace = $rs + [void]$ps.AddScript({ + function Send-Response($Stream, [string]$Status, [string]$ContentType, [byte[]]$Body) { + $head = "HTTP/1.1 $Status`r`nContent-Type: $ContentType`r`nContent-Length: $($Body.Length)`r`nCache-Control: no-store`r`nConnection: close`r`n`r`n" + $headBytes = [System.Text.Encoding]::ASCII.GetBytes($head) + $Stream.Write($headBytes, 0, $headBytes.Length) + $Stream.Write($Body, 0, $Body.Length) + $Stream.Flush() + } + while ($true) { + try { $client = $Listener.AcceptTcpClient() } catch { break } # Stop() ends the loop + try { + $client.ReceiveTimeout = 2000 + $stream = $client.GetStream() + $reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::ASCII, $false, 1024, $true) + $request = $reader.ReadLine() + # Drain headers so the client doesn't see a reset mid-send. + while ($true) { $h = $reader.ReadLine(); if ($null -eq $h -or $h -eq "") { break } } + if ($request -match "^GET /progress") { + $snapshot = @{ + status = $State.status + message = $State.message + } | ConvertTo-Json -Compress + Send-Response $stream "200 OK" "application/json; charset=utf-8" ([System.Text.Encoding]::UTF8.GetBytes($snapshot)) + } elseif ($request -match "^GET / ") { + Send-Response $stream "200 OK" "text/html; charset=utf-8" $HtmlBytes + } else { + Send-Response $stream "404 Not Found" "text/plain" ([System.Text.Encoding]::ASCII.GetBytes("not found")) + } + } catch { + # Per-connection failure: drop it, keep serving. + } finally { + try { $client.Close() } catch {} + } + } + }) + [void]$ps.BeginInvoke() + + return @{ Listener = $listener; Runspace = $rs; PowerShell = $ps; Port = $port; EdgeProc = $null } + } catch { + try { if ($listener) { $listener.Stop() } } catch {} + return $null + } +} + +function Stop-UiServer([switch]$LeaveWindow) { + if (-not $script:UiServer) { return } + try { $script:UiServer.Listener.Stop() } catch {} + try { $script:UiServer.PowerShell.Stop() } catch {} + try { $script:UiServer.Runspace.Close() } catch {} + # On success the window closes itself out from under the user (the whole + # point); on error we LEAVE it — the page holds the failure state and the + # user closes it when they've read it. + if (-not $LeaveWindow) { + try { + if ($script:UiServer.EdgeProc -and -not $script:UiServer.EdgeProc.HasExited) { + $script:UiServer.EdgeProc.CloseMainWindow() | Out-Null + } + } catch {} + } + $script:UiServer = $null +} + +function Publish-UiEvent([string]$Status, [string]$Message) { + # The event the shim listens for. One beat of poll latency (400ms) before + # teardown so the page actually renders the terminal state. + $script:UiState.message = $Message + $script:UiState.status = $Status + if ($script:UiServer) { Start-Sleep -Milliseconds 900 } +} + +# ── Fallback card (no Edge / no HTML): same shape in WinForms ────────────── +# Matches the shim pixel-for-pixel in spirit -- loader, one title, one static +# line, OS light/dark -- so degrading is invisible to the user. +function Get-AppsUseLightTheme { + try { + $v = Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" -Name AppsUseLightTheme -ErrorAction Stop + return [int]$v.AppsUseLightTheme -ne 0 + } catch { return $true } +} + +function Show-ProgressWindow { + if ($NoUi) { return } + + # ── Primary: the HTML shim in a chromeless Edge app window ───────────── + # Same footprint as the card (280x320), spawned as a normal window: it + # claims attention once by appearing, then competes with nothing. + $htmlPath = Get-UiHtmlPath + $edge = Find-EdgeExe + if ($htmlPath -and $edge) { + $server = Start-UiServer $htmlPath + if ($server) { + try { + # Dedicated tiny profile dir: guarantees a NEW WINDOW + process + # we own (a default-profile launch delegates to an existing + # Edge and returns instantly, leaving nothing to close), and + # avoids touching the user's real browser profile. + $edgeProfile = Join-Path $TempDir ("hermes-update-ui-{0}" -f $PID) + $edgeArgs = @( + "--app=http://127.0.0.1:$($server.Port)/", + "--user-data-dir=$edgeProfile", + "--no-first-run", "--no-default-browser-check", + "--disable-features=msImplicitSignin", + "--window-size=280,320" + ) + $server.EdgeProc = Start-Process -FilePath $edge -ArgumentList $edgeArgs -PassThru + $script:UiServer = $server + Write-HandoffLog "shim: Edge app window on 127.0.0.1:$($server.Port)" + return + } catch { + try { $server.Listener.Stop() } catch {} + # fall through to WinForms + } + } + } + + try { + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $light = Get-AppsUseLightTheme + # Dark seeds are the settled installer palette: neutral charcoal, + # never brand blue. + if ($light) { + $back = [System.Drawing.Color]::White + $fore = [System.Drawing.ColorTranslator]::FromHtml("#1A1A1A") + $mute = [System.Drawing.ColorTranslator]::FromHtml("#6B6B6B") + } else { + $back = [System.Drawing.ColorTranslator]::FromHtml("#232323") + $fore = [System.Drawing.ColorTranslator]::FromHtml("#F5F5F5") + $mute = [System.Drawing.ColorTranslator]::FromHtml("#A8A8A8") + } + $form = New-Object System.Windows.Forms.Form + $form.Text = "Hermes" + $form.FormBorderStyle = "FixedSingle" + $form.MaximizeBox = $false + $form.MinimizeBox = $false + $form.ControlBox = $false + $form.ClientSize = New-Object System.Drawing.Size(280, 320) + $form.StartPosition = "CenterScreen" + $form.BackColor = $back + + $bar = New-Object System.Windows.Forms.ProgressBar + $bar.Style = "Marquee" + $bar.MarqueeAnimationSpeed = 30 + $bar.SetBounds(60, 128, 160, 8) + $title = New-Object System.Windows.Forms.Label + $title.Text = "Updating Hermes" + $title.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 12) + $title.ForeColor = $fore + $title.TextAlign = "MiddleCenter" + $title.SetBounds(16, 156, 248, 28) + $sub = New-Object System.Windows.Forms.Label + $sub.Text = "Hermes will open once done." + $sub.Font = New-Object System.Drawing.Font("Segoe UI", 9) + $sub.ForeColor = $mute + $sub.TextAlign = "TopCenter" + $sub.SetBounds(24, 190, 232, 48) + $form.Controls.Add($bar) + $form.Controls.Add($title) + $form.Controls.Add($sub) + $form.Show() + # `cmd start /min` spawned us backgrounded, so the card comes up + # behind everything without one explicit activation. Claim it ONCE + # (so the user knows the update started), then never again — the + # window is decoration and competes with nothing (no TopMost). + try { + $form.Activate() + if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($form.Handle) | Out-Null } + } catch {} + [System.Windows.Forms.Application]::DoEvents() + $script:Ui = [pscustomobject]@{ Form = $form; Bar = $bar; Title = $title; Sub = $sub } + } catch { + # Headless session / WinForms unavailable: degrade to log-only. + $script:Ui = $null + } +} + +function Show-ErrorFinale([string]$Message) { + # Terse by design: a title + the debug-share pointer. No error text, no + # log tail -- `hermes debug share` uploads the real evidence and the + # relaunched Desktop surfaces the result message. + if ($script:UiServer) { + # The shim renders the error state itself; leave the window up for + # the user to read and close. Nothing to hold for — the page keeps + # the state after the listener dies. + Publish-UiEvent "error" $Message + Stop-UiServer -LeaveWindow + return + } + if (-not $script:Ui) { return } + try { + $ui = $script:Ui + $ui.Bar.Visible = $false + $ui.Title.Text = "Failed to update" + $ui.Sub.Text = "Run `"hermes debug share`" in a terminal to send a report." + $close = New-Object System.Windows.Forms.Button + $close.Text = "Close" + $close.SetBounds(100, 252, 80, 28) + $close.FlatStyle = "Flat" + $close.ForeColor = $ui.Title.ForeColor + $script:ErrorDismissed = $false + $close.Add_Click({ $script:ErrorDismissed = $true }) + $ui.Form.Controls.Add($close) + $ui.Form.AcceptButton = $close + try { + $ui.Form.Activate() + if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($ui.Form.Handle) | Out-Null } + } catch {} + # Hold for dismissal so the failure is actually seen, but never park + # forever -- the marker is already cleaned up and the relaunched + # Desktop re-surfaces the failure, so walking away costs nothing. + $deadline = (Get-Date).AddMinutes(5) + while (-not $script:ErrorDismissed -and (Get-Date) -lt $deadline -and $ui.Form.Visible) { + [System.Windows.Forms.Application]::DoEvents() + Start-Sleep -Milliseconds 100 + } + } catch {} +} + +function Show-ManualFinale([string]$Message) { + # Update landed but the Desktop did not verifiably come back. Same terse + # shape as the error finale, success glyph semantics: the shim renders + # `manual` itself; the WinForms card swaps its copy. Held so the user + # actually sees the instruction — this window is the only surface until + # they reopen Hermes themselves. + if ($script:UiServer) { + Publish-UiEvent "manual" $Message + Stop-UiServer -LeaveWindow + return + } + if (-not $script:Ui) { return } + try { + $ui = $script:Ui + $ui.Bar.Visible = $false + $ui.Title.Text = "Update complete" + $ui.Sub.Text = $Message + $close = New-Object System.Windows.Forms.Button + $close.Text = "Close" + $close.SetBounds(100, 252, 80, 28) + $close.FlatStyle = "Flat" + $close.ForeColor = $ui.Title.ForeColor + $script:ErrorDismissed = $false + $close.Add_Click({ $script:ErrorDismissed = $true }) + $ui.Form.Controls.Add($close) + $ui.Form.AcceptButton = $close + try { + $ui.Form.Activate() + if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($ui.Form.Handle) | Out-Null } + } catch {} + $deadline = (Get-Date).AddMinutes(5) + while (-not $script:ErrorDismissed -and (Get-Date) -lt $deadline -and $ui.Form.Visible) { + [System.Windows.Forms.Application]::DoEvents() + Start-Sleep -Milliseconds 100 + } + } catch {} +} + +function Close-ProgressWindow { + if ($script:UiServer) { + # Success event: the shim flips to the checkmark, then the window + # closes out from under the user as the Desktop comes back. + Publish-UiEvent "done" "" + Stop-UiServer + } + if ($script:Ui) { + try { $script:Ui.Form.Close() } catch {} + $script:Ui = $null + } +} + +function Write-Result([bool]$Ok, [int]$Code, [string]$Message, [bool]$ManualAction = $false) { + # Consumed (read + deleted) by the relaunched Desktop on boot so the + # user actually SEES how a detached update ended. $ManualAction marks an + # ok result the user still must act on -- the Desktop surfaces those in + # a dialog, not just the log (same protocol as posix.sh). + try { + $obj = @{ + ok = $Ok + exit_code = $Code + manual = $ManualAction + message = $Message + branch = $Branch + finished_at = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture) + } | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText($ResultPath, $obj) + } catch {} +} + +function Remove-MarkerIfOwned { + if ($NoMarkerCleanup) { return } + try { + if (Test-Path -LiteralPath $MarkerPath) { + $firstLine = (Get-Content -LiteralPath $MarkerPath -TotalCount 1 -ErrorAction SilentlyContinue) + if ("$firstLine".Trim() -eq "$PID") { + Remove-Item -LiteralPath $MarkerPath -Force -ErrorAction SilentlyContinue + Write-HandoffLog "removed update marker (owned)" + } else { + Write-HandoffLog "leaving update marker: owned by pid '$firstLine', not us ($PID)" + } + } + } catch {} +} + +function Start-DesktopRelaunch { + # Returns $true only when a launch VERIFIABLY happened (WMI accepted and + # the pid exists, or the fallback spawn returned a live process). The + # finally block downgrades the on-screen/on-disk outcome when it didn't + # — the sibling truth contract to posix.sh's launch acceptance. + if (-not ($RelaunchExe -and (Test-Path -LiteralPath $RelaunchExe))) { return $false } + Write-HandoffLog "relaunching desktop: $RelaunchExe" + # DO NOT spawn Hermes.exe as our child: Electron/Chromium calls + # AttachConsole(ATTACH_PARENT_PROCESS) at boot, so a Desktop launched + # directly from this console PowerShell latches onto OUR console -- + # the console window then outlives the script (it can't close while + # an attached process lives), and closing it kills the freshly + # relaunched GUI with it. Create the process via WMI instead: the + # parent becomes WmiPrvSE.exe and there is no console to inherit or + # attach -- same detachment explorer.exe gives a normal launch. + $spawned = $false + try { + $workDir = Split-Path -Parent $RelaunchExe + $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ + CommandLine = ('"{0}"' -f $RelaunchExe) + CurrentDirectory = $workDir + } -ErrorAction Stop + if ($r -and $r.ReturnValue -eq 0) { + Write-HandoffLog "desktop relaunched detached (pid $($r.ProcessId))" + $spawned = $true + # Hand our foreground rights to the new Desktop and focus its + # main window once it exists. A WMI-spawned process starts + # unfocused, and Windows only lets the CURRENT foreground + # owner (us, while the progress window is up / just closed) + # delegate that right. Poll briefly for the window: Electron + # takes a couple seconds to create it. + try { + if ($script:Win32) { + [HermesHandoff.Win32]::AllowSetForegroundWindow([int]$r.ProcessId) | Out-Null + $deadline = (Get-Date).AddSeconds(20) + while ((Get-Date) -lt $deadline) { + $hwnd = [System.IntPtr]::Zero + try { + $p = Get-Process -Id $r.ProcessId -ErrorAction Stop + $hwnd = $p.MainWindowHandle + } catch { + # Process died before showing a window — that is a + # failed launch, not merely an unfocused one. + Write-HandoffLog "WARNING: relaunched desktop exited before its window appeared" + $spawned = $false + break + } + if ($hwnd -ne [System.IntPtr]::Zero) { + [HermesHandoff.Win32]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE + [HermesHandoff.Win32]::SetForegroundWindow($hwnd) | Out-Null + Write-HandoffLog "focused relaunched desktop window" + break + } + Start-Sleep -Milliseconds 400 + } + } + } catch { + Write-HandoffLog "WARNING: could not focus relaunched desktop: $($_.Exception.Message)" + } + } else { + Write-HandoffLog "WARNING: WMI relaunch returned $($r.ReturnValue); falling back" + } + } catch { + Write-HandoffLog "WARNING: WMI relaunch failed: $($_.Exception.Message); falling back" + } + if (-not $spawned) { + try { + # Fallback keeps the old behavior (console tie-in and all) -- + # a tethered Desktop beats no Desktop. + $p = Start-Process -FilePath $RelaunchExe -WorkingDirectory (Split-Path -Parent $RelaunchExe) -PassThru + Start-Sleep -Milliseconds 1500 + if ($p -and -not $p.HasExited) { $spawned = $true } + elseif ($p) { Write-HandoffLog "WARNING: fallback relaunch exited immediately" } + } catch { + Write-HandoffLog "WARNING: desktop relaunch failed: $($_.Exception.Message)" + } + } + return $spawned +} + +function Invoke-HermesStep([string]$Exe, [string[]]$HermesArgs, [string]$Tag) { + # The window shows nothing live, so no line-pump: both pipes drain + # asynchronously (no deadlock however chatty the child) while a small + # DoEvents loop keeps the marquee animating through long silent + # stretches (pip installs) -- the old EndOfStream pump blocked on quiet + # children and froze it. Full output still lands in the hand-off log + # afterwards, where `hermes debug share` picks it up. + # System.Diagnostics.Process directly: Start-Process's .ExitCode is + # unreliably $null under PS 5.1 even with the Handle-touch workaround. + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $Exe + # .Arguments string (PS 5.1 / .NET Framework has no ArgumentList). + # Args here are fixed flags + a branch ref; quote each defensively. + $psi.Arguments = ($HermesArgs | ForEach-Object { '"{0}"' -f ($_ -replace '"', '\"') }) -join ' ' + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + # hermes update prints UTF-8 (checkmarks, arrows, box glyphs). PS 5.1 + # defaults these readers to the OEM codepage, which mangles every + # multi-byte glyph into mojibake in the log. + $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 + $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 + # And ask the child to actually EMIT UTF-8: Python decides its stdio + # encoding from the console codepage when attached to one. + $psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8" + $psi.EnvironmentVariables["PYTHONUTF8"] = "1" + $psi.CreateNoWindow = $true + $proc = [System.Diagnostics.Process]::Start($psi) + $outTask = $proc.StandardOutput.ReadToEndAsync() + $errTask = $proc.StandardError.ReadToEndAsync() + while (-not $proc.HasExited) { + Start-Sleep -Milliseconds 150 + if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } + } + $proc.WaitForExit() + $outText = $outTask.Result + $errText = $errTask.Result + foreach ($ln in ($outText -split "`r?`n")) { + if ($ln.Trim()) { Write-HandoffLog ("{0}| {1}" -f $Tag, $ln) } + } + foreach ($ln in ($errText -split "`r?`n")) { + if ($ln.Trim()) { Write-HandoffLog ("{0}!| {1}" -f $Tag, $ln) } + } + $all = $outText + if ($errText) { $all += "`n" + $errText } + return @{ Code = $proc.ExitCode; Output = $all } +} + +$finalCode = 1 +$finalMsg = "update did not complete" + +# ── -SelfTestUi: drive the shim to both terminal states, no update ───────── +# Manual QA for the Edge shell without a checkout or a real update. Exits +# before the marker/desktop/venv machinery — touches nothing. Off Windows +# (or without Edge) the loopback server still starts and the URL prints, so +# the page can be QA'd in any browser; HERMES_SELFTEST_FAIL=1 exercises the +# error state, HERMES_SELFTEST_HOLD_SECONDS delays the terminal event. +if ($SelfTestUi) { + New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null + Show-ProgressWindow + if (-not $script:UiServer) { + $htmlPath = Get-UiHtmlPath + if ($htmlPath) { + $script:UiServer = Start-UiServer $htmlPath + } + } + if ($script:UiServer) { + Write-Host "SELF-TEST: shim at http://127.0.0.1:$($script:UiServer.Port)/" + } + Write-HandoffLog "SELF-TEST: shim simulation (no update will run)" + $hold = 6 + if ($env:HERMES_SELFTEST_HOLD_SECONDS) { $hold = [int]$env:HERMES_SELFTEST_HOLD_SECONDS } + Start-Sleep -Seconds $hold + if ($env:HERMES_SELFTEST_FAIL) { + Show-ErrorFinale "self-test error state" + } else { + Close-ProgressWindow + } + exit 0 +} + +try { + New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null + Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue + Show-ProgressWindow + Write-HandoffLog "hand-off start: root=$InstallRoot branch=$Branch desktopPid=$DesktopPid pid=$PID" + + # -- 0. Claim the update marker with OUR pid --------------------------- + try { + $epoch = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture) + # WriteAllText for byte-exact LF framing: Set-Content emits CRLF and + # the marker contract (Rust/TS/Python readers) is "\n\n". + [System.IO.File]::WriteAllText($MarkerPath, "$PID`n$epoch`n") + Write-HandoffLog "claimed update marker (pid $PID)" + } catch { + Write-HandoffLog "WARNING: could not write update marker: $($_.Exception.Message)" + } + + # -- 1. Wait for the Desktop to exit (FAIL CLOSED) ---------------------- + if ($DesktopPid -gt 0) { + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + $proc = Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue + if (-not $proc) { break } + Start-Sleep -Milliseconds 300 + if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } + } + if (Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue) { + # A live Desktop means a live backend re-locking the venv at any + # moment. Updating under it is how installs brick. Abort. + $finalCode = 4 + $finalMsg = "Update aborted: the Hermes window (pid $DesktopPid) did not exit within 30s. Nothing was changed. Close Hermes fully and try again." + Write-HandoffLog $finalMsg + exit $finalCode + } + Write-HandoffLog "desktop exited" + } + + # -- 2. Wait for the venv shim to unlock (FAIL CLOSED) ------------------ + $shim = Join-Path $InstallRoot "venv\Scripts\hermes.exe" + if (Test-Path -LiteralPath $shim) { + $unlocked = $false + $deadline = (Get-Date).AddSeconds(20) + while ((Get-Date) -lt $deadline) { + try { + $fs = [System.IO.File]::Open($shim, 'Open', 'ReadWrite', 'None') + $fs.Close() + $unlocked = $true + break + } catch { + Start-Sleep -Milliseconds 400 + if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() } + } + } + if (-not $unlocked) { + # Something still maps the venv. --force-ing past it guarantees a + # half-updated venv (the exact 2026-08-09 Access-denied brick). + $finalCode = 5 + $finalMsg = "Update aborted: another process is still holding the Hermes install open (venv\Scripts\hermes.exe locked after 20s). Nothing was changed. Close other Hermes windows/terminals and try again." + Write-HandoffLog $finalMsg + exit $finalCode + } + Write-HandoffLog "venv shim unlocked" + } + + # -- 3. Run the update from the CURRENT checkout ------------------------ + # --force skips only the hermes.exe shim guard, which step 2 just PROVED + # is unlocked; the venv-python holder guard (orphan reap included) stays + # active. Our marker claim is adopted by the child via update_lock.py's + # process-ancestry rule. + $hermesExe = Join-Path $InstallRoot "venv\Scripts\hermes.exe" + if (-not (Test-Path -LiteralPath $hermesExe)) { + $finalCode = 3 + $finalMsg = "Update aborted: $hermesExe is missing. The install needs repair (run the Hermes installer or `hermes doctor`)." + Write-HandoffLog $finalMsg + exit $finalCode + } + $updateArgs = @("update", "--yes", "--gateway", "--force", "--branch", $Branch) + Write-HandoffLog ("running: hermes " + ($updateArgs -join " ")) + $res = Invoke-HermesStep $hermesExe $updateArgs "update" + Write-HandoffLog "hermes update exit code: $($res.Code)" + + if ($res.Code -ne 0 -and $res.Code -ne 2) { + # One retry for the update-boundary class (fresh code on disk, stale + # code in memory). Exit 2 ("close all Hermes windows") is not retryable. + Write-HandoffLog "first attempt failed; retrying once (freshly pulled fix loads on the second run)" + $res = Invoke-HermesStep $hermesExe $updateArgs "update" + Write-HandoffLog "retry exit code: $($res.Code)" + } + + # -- 4. Truthful completion: don't trust exit 0 ------------------------- + # `hermes update` treats a Desktop GUI build failure as NON-fatal (prints + # a one-line warning, exits 0). For a Desktop-DRIVEN update that warning + # is fatal: we would relaunch the old exe and call it success. Detect it, + # retry the build once, and propagate honestly. + $desktopBuildFailed = $false + if ($res.Code -eq 0 -and $res.Output -match "Desktop build failed") { + Write-HandoffLog "hermes update reported a desktop build failure (non-fatal there, fatal here); retrying build" + $rebuild = Invoke-HermesStep $hermesExe @("desktop", "--force-build", "--build-only") "rebuild" + Write-HandoffLog "desktop rebuild exit code: $($rebuild.Code)" + if ($rebuild.Code -ne 0) { $desktopBuildFailed = $true } + } + + if ($res.Code -eq 0 -and -not $desktopBuildFailed) { + $finalCode = 0 + $finalMsg = "Update complete." + } elseif ($desktopBuildFailed) { + $finalCode = 6 + $finalMsg = "Code and dependencies updated, but the Desktop app REBUILD FAILED - you are running the previous build. Run `hermes desktop --force-build` from a terminal to retry." + } else { + $finalCode = $res.Code + $finalMsg = "Update failed (exit $($res.Code)). Run `hermes debug share` in a terminal to send a report." + } + exit $finalCode +} finally { + # Truth ordering (sibling contract to posix.sh finish()): + # 1. durable result + marker removal (the relaunched Desktop consumes + # the result on boot and must not park on our marker); + # 2. attempt the relaunch and require ACCEPTANCE; + # 3. only then the terminal UI state — done means "Hermes is back", + # manual means "it is not, reopen it", error is error (and still + # tries to bring the app back after showing itself). + Write-Result ($finalCode -eq 0) $finalCode $finalMsg + Remove-MarkerIfOwned + if ($finalCode -ne 0) { + Show-ErrorFinale $finalMsg + Close-ProgressWindow + [void](Start-DesktopRelaunch) + } else { + $cameBack = Start-DesktopRelaunch + if (-not $cameBack -and $RelaunchExe) { + # Launch was due and did not verifiably land: truthful result + # for the next boot, manual state held on screen now. + $finalMsg = "Update complete. Reopen Hermes to finish (it could not restart itself)." + Write-Result $true 0 $finalMsg $true + Show-ManualFinale $finalMsg + } + Close-ProgressWindow + } +}