diff --git a/apps/desktop/electron/handoff-result.ts b/apps/desktop/electron/handoff-result.ts index c724be66e3118..1cfbfac55cdf1 100644 --- a/apps/desktop/electron/handoff-result.ts +++ b/apps/desktop/electron/handoff-result.ts @@ -1,7 +1,7 @@ /** * 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", diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 0a2c4a968584e..66db2716093b6 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -197,18 +197,9 @@ 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 { + resolvePosixScriptHandoff, resolveStagedUpdaterBinary, resolveUpdateScriptHandoff, spawnUpdaterProcess, @@ -1813,7 +1804,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 @@ -2868,14 +2859,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 +3015,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 +3213,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 +3315,108 @@ 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 (script relaunches + // only when it actually replaced it — release/*-unpacked — and the + // sandbox helper is launchable; otherwise the result message says so). + 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 }) - - // 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 }) - } - - return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' }) + 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 -}