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:
parent
ca5b015e72
commit
bdb4cfd35e
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue