fix(update): fail-closed cd, rejected-launch semantics, guaranteed recovery surface

gille's round 3:

- cd into the install root FAILS CLOSED (set -u without set -e let a
  failed cd continue hermes update in the caller's tree -- the exact
  wrong-tree class the correction exists to kill). Honest result, exit 3.
- A supplied mac relaunch target that is missing is a REJECTED launch ->
  manual downgrade; the launch matrix asserts the downgrade instead of
  codifying the old false success. A mac swap-failure DONE_NOTE now still
  relaunches the kept/rolled-back bundle before publishing manual.
- notify_fallback: every rung falls through on EXECUTION failure (a
  notify-send that can't reach D-Bus no longer eats the message), mac
  gets osascript (present on every macOS -- Safari-only machines have no
  chromium shim), and the no-surface terminal case is an explicit logged
  contract: the result file carries the outcome to the next boot.
- update:repro:fresh passes --non-interactive explicitly (prompt_yes_no
  falls back to /dev/tty, so </dev/null was not equivalent).
This commit is contained in:
Brooklyn Nicholson
2026-08-11 02:52:06 -05:00
parent 1dd5c9de83
commit ba28a18b95
2 changed files with 46 additions and 30 deletions
+42 -25
View File
@@ -79,30 +79,36 @@ json_escape() { # minimal JSON string escape: \ " and control whitespace
printf '%s' "$s"
}
notify_fallback() { # status message — renderer-free recovery surface.
# Fires only when there is no shim window: a gated/failed outcome must
# never be a silent disappearance (gille rounds 2-3). Each rung FALLS
# THROUGH on execution failure (notify-send existing but unable to reach
# D-Bus must not eat the message), ending at osascript on mac — present
# on every macOS — and at a logged last resort everywhere.
case "$1" in manual|error) ;; *) return 0 ;; esac
if [ "$(uname)" = "Darwin" ]; then
/usr/bin/osascript -e "display notification \"$(printf '%s' "$2" | sed 's/"/\\"/g')\" with title \"Hermes update\"" 2>/dev/null && return 0
else
if command -v notify-send >/dev/null 2>&1; then
notify-send -u critical "Hermes update" "$2" 2>/dev/null && return 0
fi
if command -v zenity >/dev/null 2>&1; then
(zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &) && return 0
fi
if command -v kdialog >/dev/null 2>&1; then
(kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &) && return 0
fi
fi
# Explicit contract for the no-surface case: the result file already
# tells the next boot the truth; log that this was the only channel.
log "NOTICE: no notification surface available; outcome reaches the user via the result file on next launch: $2"
}
publish() { # status message -- atomic replace; the server reads per poll
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
# Renderer-free recovery surface (gille's review, round 2): on linux a
# gated skew/manual outcome or a failure may be the ONLY signal the user
# gets — the Desktop deliberately does not reopen, and without a
# chromium-family browser there is no shim window. libnotify/zenity ship
# with every desktop environment; best-effort, never fatal, mac excluded
# (the shim browser list is effectively always satisfiable there and
# `open` reopens the app even on error).
if [ -z "$UI_SERVER_PID" ] && [ "$(uname)" != "Darwin" ]; then
case "$1" in
manual|error)
if command -v notify-send >/dev/null 2>&1; then
notify-send -u critical "Hermes update" "$2" 2>/dev/null || true
elif command -v zenity >/dev/null 2>&1; then
(zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &) || true
elif command -v kdialog >/dev/null 2>&1; then
(kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &) || true
fi
;;
esac
fi
[ -z "$UI_SERVER_PID" ] && notify_fallback "$1" "$2"
}
find_browser() {
@@ -245,7 +251,9 @@ launch_app() { # attempted BEFORE the terminal event (launch acceptance is
# was due but did not verifiably happen; caller downgrades to manual.
[ -n "$RELAUNCH_TARGET" ] || return 0
if [ "$(uname)" = "Darwin" ]; then
[ -d "$RELAUNCH_TARGET" ] || return 0
# A supplied target that no longer exists is a REJECTED launch (the
# swap failed badly or the bundle vanished) — not "no launch due".
[ -d "$RELAUNCH_TARGET" ] || { log "WARNING: relaunch target missing: $RELAUNCH_TARGET"; return 1; }
/usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true
# `open` talks to launchd and FAILS LOUDLY on a broken/unlaunchable
# bundle — its exit code IS launch acceptance here.
@@ -300,8 +308,12 @@ finish() {
fi
if [ -n "$DONE_NOTE" ]; then
# Gated (skew/manual): no launch will happen by design. Say so and
# leave the window up — it is the only surface until the next boot.
if [ "$(uname)" = "Darwin" ]; then
# mac DONE_NOTE = swap failed but the PREVIOUS bundle was kept/rolled
# back — bring it back up; the note still tells the user to re-run.
# A gated linux outcome (skew/manual) skips the launch BY DESIGN.
launch_app || true
fi
publish "manual" "$DONE_NOTE"; stop_ui leave-window
elif launch_app; then
publish "done" ""; stop_ui
@@ -360,8 +372,13 @@ HERMES_BIN="$INSTALL_ROOT/venv/bin/hermes"
# Run FROM the install root: `hermes update` resolves the tree it mutates
# from the working directory, and we inherit the Desktop's cwd (which can be
# an unrelated repo — updating THAT instead of the install is the failure
# the sandbox repro caught). The in-app path always passed cwd:updateRoot.
cd "$INSTALL_ROOT"
# the sandbox repro caught). FAIL CLOSED: set -u without set -e means a
# failed cd would otherwise continue in the wrong tree — the exact class
# this correction exists to eliminate.
cd "$INSTALL_ROOT" || {
FINAL_CODE=3 FINAL_MSG="Update aborted: cannot enter the install root ($INSTALL_ROOT). Nothing was changed."
log "$FINAL_MSG"; exit 3
}
export PYTHONUNBUFFERED=1
log "running: hermes update --yes --gateway --branch $BRANCH"
OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$?
+4 -5
View File
@@ -46,10 +46,8 @@ ensure_sandbox_install() {
mkdir -p "$SANDBOX"
# The literal user path: install.sh against a clone of THIS checkout, so
# the repro reproduces what you're about to ship, not origin/main.
# </dev/null = non-TTY stdin, which install.sh treats as non-interactive
# (it has no --no-interactive flag); --skip-setup skips the config wizard.
git clone --quiet "$REPO_ROOT" "$SANDBOX_ROOT"
HERMES_HOME="$SANDBOX" bash "$SANDBOX_ROOT/scripts/install.sh" --skip-setup --hermes-home "$SANDBOX" < /dev/null
HERMES_HOME="$SANDBOX" bash "$SANDBOX_ROOT/scripts/install.sh" --non-interactive --skip-setup --hermes-home "$SANDBOX"
}
case "$MODE" in
@@ -163,10 +161,11 @@ case "$MODE" in
--relaunch-target "$UNPACKED/hermes" >/dev/null 2>&1 || true
expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and 'Reopen Hermes' in d['message']"
else
# mac: `open` on a nonexistent bundle is the acceptance-failure analog
# mac: a SUPPLIED target that is missing is a REJECTED launch and
# must downgrade to manual — never a clean "Update complete."
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
--relaunch-target "$L/NoSuch.app" >/dev/null 2>&1 || true
expect_msg "missing bundle -> clean result (no launch due)" "d['ok']==True"
expect_msg "missing bundle downgrades to manual" "d['ok']==True and 'Reopen Hermes' in d['message']"
fi
# 2. gated skew: success result carries the skew message (the manual