mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-24 10:52:21 +00:00
On a Linux source install the in-app updater ran the full backend update +
desktop rebuild successfully but never restarted the app — it hung forever on
the applying overlay with no close button. Two causes:
- applyUpdatesPosixInApp() only handled the macOS .app bundle swap;
runningAppBundle() is null off macOS, so Linux fell through to
{ ok: true, backendUpdated: true } without ever relaunching.
- The renderer store had no terminal state for that result shape, so
$updateApply stayed { applying: true } and the overlay's close button
(hidden while applying) never appeared.
Fix (new electron/update-relaunch.cjs, pure + unit-tested):
- Decide the Linux outcome from whether the *running* binary is the one we
just rebuilt (execPath under release/<plat>-unpacked, path-segment-aware so
linux-unpacked-evil can't masquerade) and whether its chrome-sandbox helper
is launchable (root:root + setuid, or an --no-sandbox / ELECTRON_DISABLE_SANDBOX
opt-out):
relaunch — detached watcher waits for this PID to exit (graceful, then
SIGKILL), self-deletes, and re-execs the rebuilt binary with the original
launch context (filtered args + HERMES_*/sandbox env + cwd) restored.
guiSkew — AppImage/.deb/.rpm/dev: backend updated but this GUI package was
NOT changed; surface an honest closeable 'reinstall the desktop app'
terminal state instead of lying that it loads next launch (#37541 skew).
manual — rebuilt binary but sandbox helper not launchable: keep the
working window, don't quit into a dead app.
- store/updates.ts lands a terminal, closeable state for EVERY resolved apply
outcome (handedOff / guiSkew / manualRestart / updated-not-relaunched / error)
so the hang is impossible regardless of platform or result.
- New DesktopUpdateStage values (update/rebuild/done/guiSkew) + GuiSkewView so
progress reads correctly and the skew state is closeable. i18n in all four
locales (en/ja/zh/zh-hant) in parity.
- electron/update-relaunch.test.cjs (16 tests) + store outcome tests.
Salvaged from #45205 onto current main. Linux quit dwell uses the shared
UPDATE_HANDOFF_DWELL_MS (2.5s) from #50448 for consistency. Four-locale i18n
parity, AUTHOR_MAP entry, and the test wiring added on top.
Closes #45205.
265 lines
11 KiB
JavaScript
265 lines
11 KiB
JavaScript
'use strict'
|
|
|
|
/**
|
|
* update-relaunch.cjs — pure decision + script-generation helpers for the
|
|
* Linux in-app update relaunch (#45205).
|
|
*
|
|
* Extracted from main.cjs'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.cjs
|
|
* `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.
|
|
*/
|
|
|
|
const path = require('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.cjs). 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 : ''}
|
|
`
|
|
}
|
|
|
|
module.exports = {
|
|
unpackedDirName,
|
|
resolveUnpackedRelease,
|
|
decideRelaunchOutcome,
|
|
sandboxPreflight,
|
|
sandboxFallbackFromEnv,
|
|
collectRelaunchArgs,
|
|
collectRelaunchEnv,
|
|
buildRelaunchScript,
|
|
shellQuote,
|
|
INTERNAL_ARG_PREFIXES,
|
|
PRESERVED_ENV_KEYS,
|
|
PRESERVED_ENV_PREFIXES
|
|
}
|