fix(update): posix hand-off truth ordering, relaunch-gate port, JSON escaping

Address helix4u's review:

- finish() now delivers the outcome BEFORE publishing it: mac bundle swap
  and the linux relaunch gate run first, then the result file, marker
  removal, and the shim event -- the app launch itself goes last so it
  can't race the result write. A gated/skewed linux install (AppImage/
  deb/rpm, broken sandbox helper) surfaces its message in the result file
  AND holds the shim window open with it instead of closing on a false
  'Opening Hermes...'.
- mac swap is transactional with a checked rollback; a failed install
  restores the previous bundle and the result says so (exit 7 when even
  rollback fails). Failed 'open' rewrites the result truthfully.
- linux gate is an exact port of the deleted update-relaunch.ts logic:
  anchored path-segment match on <root>/apps/desktop/release/linux-unpacked,
  chrome-sandbox absent = namespace build = fine, present = root+setuid
  required, with the real opt-outs (ELECTRON_DISABLE_SANDBOX, --no-sandbox
  among replayed args, or the Desktop vouching) instead of the invented
  HERMES_DESKTOP_NO_SANDBOX. collectRelaunchArgs/sandboxFallbackFromEnv
  live in updater-process.ts again; the Desktop passes filtered launch
  args (after --) and --relaunch-cwd so a deep-link or --no-sandbox
  launch survives the update.
- result/status JSON strings are escaped (git permits '"' in branch
  names) and the result write is atomic (tmp + rename).
- coverage: resolvePosixScriptHandoff + ported helpers in
  updater-process.test.ts (19 pass); repro.sh gate / npm run
  update:repro:gate asserts the whole gate matrix and round-trips a
  hostile branch name through the result JSON.
This commit is contained in:
Brooklyn Nicholson 2026-08-11 00:06:57 -05:00
parent ca5b015e72
commit bdb4cfd35e
7 changed files with 343 additions and 47 deletions

View File

@ -199,9 +199,11 @@ import { waitForUpdateClearance } from './update-gate'
import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker'
import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
import {
collectRelaunchArgs,
resolvePosixScriptHandoff,
resolveStagedUpdaterBinary,
resolveUpdateScriptHandoff,
sandboxFallbackFromEnv,
spawnUpdaterProcess,
stagedUpdaterSupportsPrewrittenMarker,
wrapHandoffForDetachedConsole
@ -3371,15 +3373,31 @@ async function applyUpdatesPosixHandoff(opts: 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).
// rebuilt bundle over it), the running binary elsewhere. The script's gate
// (an exact port of update-relaunch.ts's decideRelaunchOutcome) relaunches
// only a binary the rebuild replaced with a launchable sandbox helper —
// replaying the original launch context (filtered args, cwd, sandbox
// opt-out) so a deep-link or --no-sandbox launch survives the update.
const targetApp = IS_MAC ? runningAppBundle() : process.execPath
if (targetApp) {
args.push('--relaunch-target', targetApp)
}
const relaunchArgs = collectRelaunchArgs(process.argv.slice(1))
if (!IS_MAC) {
args.push('--relaunch-cwd', process.cwd())
if (sandboxFallbackFromEnv(process.env, relaunchArgs)) {
args.push('--sandbox-fallback')
}
if (relaunchArgs.length) {
args.push('--', ...relaunchArgs)
}
}
const child = spawnUpdaterProcess(handoff.command, args, {
cwd: HERMES_HOME,
env: {

View File

@ -5,9 +5,12 @@ import path from 'node:path'
import { test } from 'vitest'
import {
collectRelaunchArgs,
MARKER_SELF_ADOPT_EPOCH_MS,
resolvePosixScriptHandoff,
resolveStagedUpdaterBinary,
resolveUpdateScriptHandoff,
sandboxFallbackFromEnv,
spawnUpdaterProcess,
stagedUpdaterSupportsPrewrittenMarker,
wrapHandoffForDetachedConsole
@ -246,3 +249,63 @@ test('wrapHandoffForDetachedConsole routes through cmd start with own console',
'main'
])
})
test('resolvePosixScriptHandoff returns the bash recipe when the script exists', () => {
const root = '/home/hermes/.hermes/hermes-agent'
const expected = path.join(root, 'scripts', 'desktop-update', 'posix.sh')
const handoff = resolvePosixScriptHandoff(root, {
isWindows: false,
fileExists: candidate => candidate === expected
})
assert.ok(handoff)
assert.equal(handoff.command, '/bin/bash')
assert.deepEqual(handoff.args, [expected])
})
test('resolvePosixScriptHandoff is null when the checkout predates the script', () => {
const handoff = resolvePosixScriptHandoff('/home/hermes/.hermes/hermes-agent', {
isWindows: false,
fileExists: () => false
})
assert.equal(handoff, null)
})
test('resolvePosixScriptHandoff is null on Windows', () => {
const handoff = resolvePosixScriptHandoff(String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`, {
isWindows: true,
fileExists: () => true
})
assert.equal(handoff, null)
})
test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', () => {
const argv = [
'--type=renderer',
'--user-data-dir=/tmp/x',
'--enable-features=A,B',
'--field-trial-handle=123',
'--enable-logging',
'--log-file=/tmp/log',
'--lang=en-US',
'--inspect=9229',
'--remote-debugging-port=9222',
'--no-sandbox',
'hermes://open/session/abc',
'--profile=work'
]
assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/session/abc', '--profile=work'])
assert.deepEqual(collectRelaunchArgs(undefined), [])
})
test('sandboxFallbackFromEnv: ELECTRON_DISABLE_SANDBOX / --no-sandbox opt out', () => {
assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: '1' }, []), true)
assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: 'true' }, []), true)
assert.equal(sandboxFallbackFromEnv({}, ['--no-sandbox']), true)
assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: '0' }, []), false)
assert.equal(sandboxFallbackFromEnv({}, []), false)
})

View File

@ -137,6 +137,57 @@ export function wrapHandoffForDetachedConsole(
}
}
/**
* Electron/Chromium internal switches that must NOT be replayed on re-exec:
* runtime artifacts of THIS launch, not user intent (ported from the deleted
* update-relaunch.ts; #45205). `--no-sandbox` is deliberately kept it is
* the user's sandbox opt-out and the signal that makes a relaunch safe when
* chrome-sandbox isn't setuid.
*/
export const INTERNAL_ARG_PREFIXES = [
'--type=',
'--user-data-dir=',
'--enable-features=',
'--disable-features=',
'--field-trial-handle=',
'--enable-logging',
'--log-file=',
'--disable-gpu-sandbox',
'--lang=',
'--inspect',
'--remote-debugging-port='
]
/** Filter Electron internals from process.argv.slice(1) so the relaunched
* app replays only user/launcher intent (deep links, app flags). */
export function collectRelaunchArgs(argv: unknown): string[] {
if (!Array.isArray(argv)) {
return []
}
return argv.filter((arg): arg is string => {
if (typeof arg !== 'string' || arg.length === 0) {
return false
}
return !INTERNAL_ARG_PREFIXES.some(prefix =>
prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=')
)
})
}
/** True when the user has opted out of the SUID sandbox the relaunch is
* safe even if chrome-sandbox fails preflight (ported from update-relaunch.ts). */
export function sandboxFallbackFromEnv(env: Record<string, string | undefined>, launchArgs: string[]): boolean {
const disable = String(env?.ELECTRON_DISABLE_SANDBOX || '').trim()
if (disable === '1' || disable.toLowerCase() === 'true') {
return true
}
return Array.isArray(launchArgs) && launchArgs.includes('--no-sandbox')
}
export interface ResolveStagedUpdaterBinaryDeps {
isWindows?: boolean
fileExists?: (candidate: string) => boolean

View File

@ -43,6 +43,7 @@
"update:repro:fresh": "bash ../../scripts/desktop-update/repro.sh fresh",
"update:repro:behind": "bash ../../scripts/desktop-update/repro.sh behind",
"update:repro:error": "bash ../../scripts/desktop-update/repro.sh error",
"update:repro:gate": "bash ../../scripts/desktop-update/repro.sh gate",
"perf:serve": "node scripts/perf/serve.mjs",
"test:desktop": "node scripts/test-desktop.mjs",
"test:desktop:all": "node scripts/test-desktop.mjs all",

View File

@ -15,26 +15,40 @@
# --desktop-pid <pid> the Electron main process to wait out
# [--relaunch-target <p>] mac: running .app to swap+reopen;
# linux: running binary (omit = no relaunch)
# [--no-ui] [--no-marker-cleanup] [--self-test-ui]
# [--relaunch-cwd <p>] linux: working directory to restore on relaunch
# [--sandbox-fallback] linux: the caller vouches for a sandbox opt-out
# (ELECTRON_DISABLE_SANDBOX / --no-sandbox launch)
# [--no-ui] [--no-marker-cleanup] [--self-test-ui] [--self-test-gate]
# [-- <args...>] linux: filtered launch args to replay
#
# The shim (ui.html in a chromeless browser app window) is decoration: it
# polls /progress for `done` or `error` and reacts. It owns nothing --
# relaunch, result file, marker hygiene all happen here, identically, when
# no renderer exists. No chromium-family browser found = no UI, fine.
#
# ORDERING (the durable-truth rule): swap and relaunch are DECIDED AND
# EXECUTED before the result file is written, the marker is removed, or a
# terminal event reaches the shim. Nothing user-visible may claim an outcome
# the filesystem hasn't already delivered.
set -u
INSTALL_ROOT="" BRANCH="main" DESKTOP_PID=0 RELAUNCH_TARGET=""
NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0
RELAUNCH_CWD="" SANDBOX_FALLBACK=0 RELAUNCH_ARGS=()
NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0 SELF_TEST_GATE=0
while [ $# -gt 0 ]; do
case "$1" in
--install-root) INSTALL_ROOT="$2"; shift 2 ;;
--branch) BRANCH="$2"; shift 2 ;;
--desktop-pid) DESKTOP_PID="$2"; shift 2 ;;
--relaunch-target) RELAUNCH_TARGET="$2"; shift 2 ;;
--relaunch-cwd) RELAUNCH_CWD="$2"; shift 2 ;;
--sandbox-fallback) SANDBOX_FALLBACK=1; shift ;;
--no-ui) NO_UI=1; shift ;;
--no-marker-cleanup) NO_MARKER_CLEANUP=1; shift ;;
--self-test-ui) SELF_TEST_UI=1; shift ;;
--self-test-gate) SELF_TEST_GATE=1; shift ;;
--) shift; RELAUNCH_ARGS=("$@"); shift $# ;;
*) echo "unknown arg: $1" >&2; exit 64 ;;
esac
done
@ -51,12 +65,23 @@ STATUS="${TMPDIR:-/tmp}/hermes-update-status.$$"
UI_SERVER_PID="" UI_BROWSER_PID="" FINAL_CODE=1
FINAL_MSG="update did not complete"
DONE_NOTE="" # set when the update succeeded but the app will NOT reopen itself
log() { echo "$(date +%Y-%m-%dT%H:%M:%S%z) $1" | tee -a "$LOG" 2>/dev/null; }
# ── shim ────────────────────────────────────────────────────────────────────
json_escape() { # minimal JSON string escape: \ " and control whitespace
local s=${1//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\n'/\\n}
s=${s//$'\r'/\\r}
s=${s//$'\t'/\\t}
printf '%s' "$s"
}
publish() { # status message -- atomic replace; the server reads per poll
printf '{"status":"%s","message":"%s"}' "$1" "$2" > "$STATUS.tmp" && mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true
printf '{"status":"%s","message":"%s"}' "$(json_escape "$1")" "$(json_escape "$2")" > "$STATUS.tmp" \
&& mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true
[ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state
}
@ -112,60 +137,152 @@ stop_ui() { # error state leaves the window up for the user to read
}
# ── relaunch ────────────────────────────────────────────────────────────────
relaunch() {
[ -n "$RELAUNCH_TARGET" ] || return 0
if [ "$(uname)" = "Darwin" ]; then
# Swap the rebuilt bundle over the running one when both resolve, then
# `open` (fully detached). POSIX doesn't lock running executables.
local rebuilt="" c
for c in "$INSTALL_ROOT/apps/desktop/release/mac-arm64/Hermes.app" \
"$INSTALL_ROOT/apps/desktop/release/mac/Hermes.app"; do
[ -d "$c" ] && { rebuilt="$c"; break; }
done
if [ -n "$rebuilt" ] && [ -d "$RELAUNCH_TARGET" ] && [ "$rebuilt" != "$RELAUNCH_TARGET" ]; then
if /usr/bin/ditto "$rebuilt" "$RELAUNCH_TARGET.new"; then
mv "$RELAUNCH_TARGET" "$RELAUNCH_TARGET.old" 2>/dev/null || rm -rf "$RELAUNCH_TARGET"
mv "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET"
rm -rf "$RELAUNCH_TARGET.old" 2>/dev/null || true
log "swapped app bundle"
else
# Linux relaunch gate -- an exact port of the deleted update-relaunch.ts
# decision (#45205/#37541), not a loosened rewrite:
# * the running binary must live under THIS checkout's rebuilt
# apps/desktop/release/linux-unpacked (anchored, path-segment-aware --
# proof the update we just ran replaced the selected executable);
# * chrome-sandbox ABSENT is fine (namespace-sandbox build; nothing to
# block on), PRESENT means root-owned AND setuid or Electron refuses to
# boot ("quit and never came back");
# * a user sandbox opt-out (ELECTRON_DISABLE_SANDBOX=1/true in our
# inherited env, --no-sandbox among the replayed launch args, or the
# Desktop vouching via --sandbox-fallback) makes the relaunch safe
# despite a failed preflight.
# Outcomes mirror decideRelaunchOutcome: relaunch | skew | manual.
GATE="" GATE_MSG=""
linux_gate() {
local unpacked="$INSTALL_ROOT/apps/desktop/release/linux-unpacked" sb arg
case "$RELAUNCH_TARGET" in
"$unpacked"/*) ;;
*) GATE=skew GATE_MSG="Backend updated, but the desktop app package (AppImage/deb/rpm) was not changed. Update or reinstall it to match."; return ;;
esac
sb="$unpacked/chrome-sandbox"
if [ ! -e "$sb" ]; then GATE=relaunch; return; fi
if [ -u "$sb" ] && [ "$(stat -c %u "$sb" 2>/dev/null)" = "0" ]; then GATE=relaunch; return; fi
case "${ELECTRON_DISABLE_SANDBOX:-}" in 1|true|TRUE|True) GATE=relaunch; return ;; esac
[ "$SANDBOX_FALLBACK" -eq 1 ] && { GATE=relaunch; return; }
for arg in ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"}; do
[ "$arg" = "--no-sandbox" ] && { GATE=relaunch; return; }
done
GATE=manual GATE_MSG="Update complete, but the rebuilt app can't relaunch itself (its sandbox helper needs root ownership). Reopen Hermes to finish."
}
mac_swap() {
local rebuilt="" c
for c in "$INSTALL_ROOT/apps/desktop/release/mac-arm64/Hermes.app" \
"$INSTALL_ROOT/apps/desktop/release/mac/Hermes.app"; do
[ -d "$c" ] && { rebuilt="$c"; break; }
done
# Transactional swap: stage a full copy, move the old bundle aside, move
# the copy in. Every step checked; a failed final move ROLLS BACK so the
# user always has a launchable app, and the result file tells the truth.
if [ "$FINAL_CODE" -eq 0 ] && [ -n "$rebuilt" ] && [ -d "$RELAUNCH_TARGET" ] && [ "$rebuilt" != "$RELAUNCH_TARGET" ]; then
rm -rf "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET.old" 2>/dev/null || true
if ! /usr/bin/ditto "$rebuilt" "$RELAUNCH_TARGET.new"; then
rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true
DONE_NOTE="Update complete, but the new app could not be staged; the previous version was kept. Run the update again."
log "WARNING: bundle copy failed; keeping existing app"
elif ! mv "$RELAUNCH_TARGET" "$RELAUNCH_TARGET.old"; then
rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true
DONE_NOTE="Update complete, but the new app could not replace the old one; the previous version was kept. Run the update again."
log "WARNING: could not move old bundle aside; keeping existing app"
elif ! mv "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET"; then
if mv "$RELAUNCH_TARGET.old" "$RELAUNCH_TARGET"; then
rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true
log "WARNING: bundle copy failed; relaunching existing app"
DONE_NOTE="Update complete, but the new app could not be installed; the previous version was restored. Run the update again."
log "WARNING: bundle install failed; rolled back to the previous app"
else
FINAL_CODE=7 FINAL_MSG="The update finished but installing the new app failed and the previous app could not be restored. Reinstall Hermes (the rebuilt app is at $rebuilt)."
log "ERROR: bundle install failed AND rollback failed"
fi
else
rm -rf "$RELAUNCH_TARGET.old" 2>/dev/null || true
log "swapped app bundle"
fi
/usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true
/usr/bin/open "$RELAUNCH_TARGET" || log "WARNING: relaunch failed"
else
# Linux: only relaunch a binary the rebuild actually replaced, with a
# launchable sandbox helper -- otherwise say so instead of lying (#37541).
case "$RELAUNCH_TARGET" in
*/release/*-unpacked/*)
if [ -u "$(dirname "$RELAUNCH_TARGET")/chrome-sandbox" ] || [ -n "${HERMES_DESKTOP_NO_SANDBOX:-}" ]; then
(setsid "$RELAUNCH_TARGET" >/dev/null 2>&1 &) || log "WARNING: relaunch failed"
else
FINAL_MSG="Update complete. Reopen Hermes to finish (the app could not restart itself)."
fi ;;
*)
FINAL_MSG="Backend updated, but the desktop app package (AppImage/deb/rpm) was not changed. Update it to match." ;;
esac
fi
}
finish() {
deliver_outcome() { # the truth-determining half: swap bundles / gate the relaunch
[ -n "$RELAUNCH_TARGET" ] || return 0
if [ "$(uname)" = "Darwin" ]; then
mac_swap
else
linux_gate
if [ "$GATE" != "relaunch" ] && [ "$FINAL_CODE" -eq 0 ]; then
DONE_NOTE="$GATE_MSG"
log "no relaunch ($GATE): $GATE_MSG"
fi
fi
}
launch_app() { # runs LAST, after the result is durable (the relaunched
# Desktop consumes the result file on boot -- launching first races the
# write). Returns nonzero when a launch was due but did not happen.
[ -n "$RELAUNCH_TARGET" ] || return 0
if [ "$(uname)" = "Darwin" ]; then
[ -d "$RELAUNCH_TARGET" ] || return 0
/usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true
/usr/bin/open "$RELAUNCH_TARGET" || { log "WARNING: relaunch failed"; return 1; }
elif [ "$GATE" = "relaunch" ]; then
# Replay the original launch context: filtered args from the Desktop
# (after --), its cwd, and its env (inherited through our own spawn).
(cd "${RELAUNCH_CWD:-/}" 2>/dev/null || cd /; setsid "$RELAUNCH_TARGET" ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"} >/dev/null 2>&1 &) \
|| { log "WARNING: relaunch failed"; return 1; }
fi
}
write_result() {
printf '{"ok":%s,"exit_code":%s,"message":"%s","branch":"%s","finished_at":%s}' \
"$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" "$FINAL_MSG" "$BRANCH" "$(date +%s)" \
> "$RESULT" 2>/dev/null || true
"$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" \
"$(json_escape "$FINAL_MSG")" "$(json_escape "$BRANCH")" "$(date +%s)" \
> "$RESULT.tmp" 2>/dev/null && mv -f "$RESULT.tmp" "$RESULT" 2>/dev/null || true
}
finish() {
# Ordering (helix4u's review): 1. deliver the outcome (swap/gate) so the
# truth exists; 2. durable result; 3. marker; 4. shim event; 5. relaunch.
deliver_outcome
[ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && FINAL_MSG="$DONE_NOTE"
write_result
if [ "$NO_MARKER_CLEANUP" -eq 0 ] && [ "$(head -1 "$MARKER" 2>/dev/null | tr -d '[:space:]')" = "$$" ]; then
rm -f "$MARKER" 2>/dev/null || true
fi
if [ "$FINAL_CODE" -eq 0 ]; then publish "done" ""; stop_ui
else publish "error" "$FINAL_MSG"; stop_ui leave-window; fi
relaunch
if [ "$FINAL_CODE" -eq 0 ]; then
# A DONE_NOTE means the app will NOT reopen itself -- leave the window
# up showing the note instead of closing on a false "Opening Hermes…".
publish "done" "$DONE_NOTE"
if [ -n "$DONE_NOTE" ]; then stop_ui leave-window; else stop_ui; fi
else
publish "error" "$FINAL_MSG"; stop_ui leave-window
fi
if ! launch_app && [ "$FINAL_CODE" -eq 0 ] && [ -z "$DONE_NOTE" ]; then
# Launch failed after "done" went out: nothing consumed the result yet
# (the app never started), so make it tell the truth for the next boot.
FINAL_MSG="Update complete. Reopen Hermes to finish (it could not restart itself)."
write_result
fi
rm -f "$STATUS" "$STATUS.tmp" "$LOG_DIR/desktop-update-ui-port" 2>/dev/null || true
}
trap finish EXIT
# ── self-test: shim only, no update, touches nothing ───────────────────────
# ── self-tests: no update, touch nothing ────────────────────────────────────
if [ "$SELF_TEST_GATE" -eq 1 ]; then
# Prints the gate decision for the given --install-root/--relaunch-target
# and exits; scripts/desktop-update/repro.sh gate asserts the matrix.
trap - EXIT
linux_gate
echo "$GATE${GATE_MSG:+:$GATE_MSG}"
exit 0
fi
if [ "$SELF_TEST_UI" -eq 1 ]; then
start_ui
log "SELF-TEST: shim simulation (no update will run)"

View File

@ -14,6 +14,9 @@
# the "user who hasn't updated in a while" path
# repro.sh error orchestrator against a broken install (missing
# venv) -- exercises abort + result-file + shim error
# repro.sh gate linux relaunch-gate decision matrix (anchoring,
# sandbox preflight, opt-out fallbacks) -- asserts
# every outcome without touching a real install
#
# The sandbox persists between runs (~/tmp is fine to nuke): fresh reuses
# nothing, behind/error reuse the last sandbox install when present because
@ -88,6 +91,46 @@ case "$MODE" in
cat "$SANDBOX/.hermes-update-result.json" 2>/dev/null || echo "(none written)"
echo
;;
gate)
# Pure-decision matrix for the linux relaunch gate. Builds a fake
# checkout layout under /tmp; --self-test-gate prints the decision and
# exits without running an update.
G="/tmp/hermes-gate-test.$$"
UNPACKED="$G/hermes-agent/apps/desktop/release/linux-unpacked"
mkdir -p "$UNPACKED"
touch "$UNPACKED/hermes" && chmod +x "$UNPACKED/hermes"
fails=0
expect() { # name expected actual
if [ "$2" = "$3" ]; then printf 'ok %s -> %s\n' "$1" "$3"
else printf 'FAIL %s -> %s (want %s)\n' "$1" "$3" "$2"; fails=$((fails+1)); fi
}
decide() { bash "$SCRIPT_DIR/posix.sh" --self-test-gate --install-root "$G/hermes-agent" "$@" | cut -d: -f1; }
expect "appimage (not under unpacked)" skew "$(decide --relaunch-target /opt/Hermes/hermes)"
expect "sibling-prefix dir not fooled" skew "$(decide --relaunch-target "$UNPACKED-evil/hermes")"
expect "no chrome-sandbox (namespace)" relaunch "$(decide --relaunch-target "$UNPACKED/hermes")"
touch "$UNPACKED/chrome-sandbox"
expect "sandbox not root/setuid" manual "$(decide --relaunch-target "$UNPACKED/hermes")"
expect "opt-out: --sandbox-fallback" relaunch "$(decide --relaunch-target "$UNPACKED/hermes" --sandbox-fallback)"
expect "opt-out: --no-sandbox launch arg" relaunch "$(decide --relaunch-target "$UNPACKED/hermes" -- --no-sandbox)"
expect "opt-out: ELECTRON_DISABLE_SANDBOX" relaunch "$(ELECTRON_DISABLE_SANDBOX=1 decide --relaunch-target "$UNPACKED/hermes")"
# Result JSON must survive hostile strings (git allows `"` in branch
# names; messages carry arbitrary text) -- parse it back with python.
QHOME="$G/qhome"; mkdir -p "$QHOME/hermes-agent"
bash "$SCRIPT_DIR/posix.sh" --no-ui --no-marker-cleanup --desktop-pid 0 \
--install-root "$QHOME/hermes-agent" --branch 'evil"branch\n$(x)' >/dev/null 2>&1 || true
if python3 -c "import json,sys; d=json.load(open('$QHOME/.hermes-update-result.json')); sys.exit(0 if d['branch']=='evil\"branch\\\\n\$(x)' and d['ok']==False else 1)"; then
printf 'ok result JSON escapes hostile branch/message\n'
else
printf 'FAIL result JSON escaping\n'; fails=$((fails+1))
fi
rm -rf "$G"
[ "$fails" -eq 0 ] && say "gate matrix: all pass" || { say "gate matrix: $fails FAILED"; exit 1; }
;;
*)
sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'
exit 64

View File

@ -212,7 +212,10 @@
if (state.status === 'done') {
settle('done')
glyphEl.textContent = '\u2713'
lineEl.textContent = 'Opening Hermes\u2026'
// A done message means the update landed but Hermes will NOT reopen
// itself (package skew, sandbox helper) — say that, not a false
// "Opening Hermes…". The orchestrator leaves the window up for it.
lineEl.textContent = state.message || 'Opening Hermes\u2026'
} else if (state.status === 'error') {
settle('error')
glyphEl.textContent = '\u2715'