refactor(desktop): replace the in-app posix updater with the hand-off
applyUpdatesPosixInApp is gone: mac/linux Update now quits into the detached posix orchestrator, same shape as Windows. Deletes everything the in-app path dragged into main.ts -- runStreamedUpdate, the rebuild retry, the relaunch-outcome matrix (update-relaunch.ts/update-rebuild.ts and tests), shellQuote, resolveHermesCliBinary -- and with the app dead before the update starts, the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance (#37532) is structurally unnecessary on the desktop path.
This commit is contained in:
parent
c991e3f62f
commit
c9f0b6824e
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>.app/Contents/MacOS/<exe>; 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<string, string> = {
|
||||
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/<plat>-unpacked.
|
||||
// We can only HONESTLY relaunch into the new GUI when the *running* binary IS
|
||||
// that rebuilt one — i.e. execPath lives under release/<plat>-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'))
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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/<plat>-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/<plat>-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/)
|
||||
})
|
||||
|
|
@ -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/<plat>-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/<plat>-unpacked` dir.
|
||||
*
|
||||
* - Source / unpacked install (execPath under release/<plat>-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/<plat>-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/<dir>-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 `<updateRoot>/apps/desktop/release/<plat>-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
|
||||
}
|
||||
Loading…
Reference in New Issue