mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
/**
|
|
* 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 }
|