diff --git a/apps/desktop/electron/updater-process.test.ts b/apps/desktop/electron/updater-process.test.ts index a694b28bc6b74..00e2e2b5d5255 100644 --- a/apps/desktop/electron/updater-process.test.ts +++ b/apps/desktop/electron/updater-process.test.ts @@ -170,7 +170,7 @@ test('resolveStagedUpdaterBinary returns null on Windows when nothing is staged' test('resolveUpdateScriptHandoff prefers the repo script on Windows when present', () => { const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent` - const expected = path.join(root, 'scripts', 'desktop-update.ps1') + const expected = path.join(root, 'scripts', 'desktop-update', 'windows.ps1') const handoff = resolveUpdateScriptHandoff(root, { isWindows: true, @@ -183,6 +183,19 @@ test('resolveUpdateScriptHandoff prefers the repo script on Windows when present assert.deepEqual(handoff.args, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', expected]) }) +test('resolveUpdateScriptHandoff falls back to the pre-reorg flat path', () => { + const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent` + const legacy = path.join(root, 'scripts', 'desktop-update.ps1') + + const handoff = resolveUpdateScriptHandoff(root, { + isWindows: true, + fileExists: candidate => candidate === legacy + }) + + assert.ok(handoff) + assert.equal(handoff.scriptPath, legacy) +}) + test('resolveUpdateScriptHandoff returns null when the checkout predates the script', () => { const handoff = resolveUpdateScriptHandoff(String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`, { isWindows: true, @@ -203,7 +216,7 @@ test('resolveUpdateScriptHandoff is Windows-only (POSIX updates in place)', () = test('wrapHandoffForDetachedConsole routes through cmd start with own console', () => { const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent` - const expected = path.join(root, 'scripts', 'desktop-update.ps1') + const expected = path.join(root, 'scripts', 'desktop-update', 'windows.ps1') const handoff = resolveUpdateScriptHandoff(root, { isWindows: true, diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index e5728361785a2..f147c1bcd408e 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -27,7 +27,7 @@ export interface UpdateScriptHandoff { * updater-side fix only reaches users when a new binary is built, signed and * published — which historically lags main by months and strands users on * long-fixed bugs (cache resolver #67369, marker self-adopt #74782; the - * 2026-08-09 incident chain). `scripts/desktop-update.ps1` lives in the repo + * 2026-08-09 incident chain). `scripts/desktop-update/windows.ps1` lives in the repo * checkout instead: every `hermes update` refreshes the code that drives the * NEXT update, and only PowerShell itself is frozen. * @@ -47,7 +47,50 @@ export function resolveUpdateScriptHandoff( return null } - const scriptPath = path.join(updateRoot, 'scripts', 'desktop-update.ps1') + const exists = deps.fileExists ?? stagedFileExists + + // Current layout first, then the pre-reorg flat path — an updated asar can + // meet a checkout from either side of the move (the checkout also ships a + // forwarder at the legacy path for the inverse skew). + for (const candidate of [ + path.join(updateRoot, 'scripts', 'desktop-update', 'windows.ps1'), + path.join(updateRoot, 'scripts', 'desktop-update.ps1') + ]) { + if (exists(candidate)) { + return { + command: 'powershell', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', candidate], + scriptPath: candidate + } + } + } + + return null +} + +/** + * Repo-owned POSIX update hand-off (the mac/linux twin of the above). + * + * Replaces the in-app posix updater: the Desktop spawns the script detached + * and QUITS, the script waits it out, runs `hermes update`, swaps/relaunches + * the app, and writes .hermes-update-result.json. With the app gone before + * the update starts, the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance is + * unnecessary — there are no live desktop backends to spare. + * + * Null when the checkout predates the script (caller surfaces the manual + * `hermes update` card — old checkouts pull the script on their next update). + */ +export function resolvePosixScriptHandoff( + updateRoot: string, + deps: ResolveUpdateScriptHandoffDeps = {} +): UpdateScriptHandoff | null { + const isWindows = deps.isWindows ?? process.platform === 'win32' + + if (isWindows) { + return null + } + + const scriptPath = path.join(updateRoot, 'scripts', 'desktop-update', 'posix.sh') const exists = deps.fileExists ?? stagedFileExists if (!exists(scriptPath)) { @@ -55,8 +98,8 @@ export function resolveUpdateScriptHandoff( } return { - command: 'powershell', - args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], + command: '/bin/bash', + args: [scriptPath], scriptPath } } diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh new file mode 100755 index 0000000000000..524803c003954 --- /dev/null +++ b/scripts/desktop-update/posix.sh @@ -0,0 +1,228 @@ +#!/bin/bash +# posix.sh -- repo-owned macOS/Linux Desktop update hand-off. +# +# The whole job: wait for the Desktop to exit, run `hermes update`, tell the +# shim how it went, reopen the app. The Desktop spawns this detached and +# quits; because it lives in the checkout, every update refreshes the code +# that drives the next one. Replaces the in-app updater +# (applyUpdatesPosixInApp) -- with the app gone before the update starts, +# the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance dies with it. +# +# CONTRACT (keep in sync with apps/desktop/electron/main.ts): +# bash scripts/desktop-update/posix.sh +# --install-root repo checkout (HERMES_HOME/hermes-agent) +# --branch branch to update against +# --desktop-pid the Electron main process to wait out +# [--relaunch-target

] mac: running .app to swap+reopen; +# linux: running binary (omit = no relaunch) +# [--no-ui] [--no-marker-cleanup] [--self-test-ui] +# +# 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. + +set -u + +INSTALL_ROOT="" BRANCH="main" DESKTOP_PID=0 RELAUNCH_TARGET="" +NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=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 ;; + --no-ui) NO_UI=1; shift ;; + --no-marker-cleanup) NO_MARKER_CLEANUP=1; shift ;; + --self-test-ui) SELF_TEST_UI=1; shift ;; + *) echo "unknown arg: $1" >&2; exit 64 ;; + esac +done +[ "$SELF_TEST_UI" -eq 1 ] || [ -n "$INSTALL_ROOT" ] || { echo "--install-root is required" >&2; exit 64; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HERMES_HOME="${INSTALL_ROOT:+$(dirname "$INSTALL_ROOT")}" +HERMES_HOME="${HERMES_HOME:-${TMPDIR:-/tmp}}" +MARKER="$HERMES_HOME/.hermes-update-in-progress" +LOG_DIR="$HERMES_HOME/logs"; mkdir -p "$LOG_DIR" 2>/dev/null || true +LOG="$LOG_DIR/desktop-update-handoff.log" +RESULT="$HERMES_HOME/.hermes-update-result.json" +STATUS="${TMPDIR:-/tmp}/hermes-update-status.$$" + +UI_SERVER_PID="" UI_BROWSER_PID="" FINAL_CODE=1 +FINAL_MSG="update did not complete" + +log() { echo "$(date +%Y-%m-%dT%H:%M:%S%z) $1" | tee -a "$LOG" 2>/dev/null; } + +# ── shim ──────────────────────────────────────────────────────────────────── +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 + [ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state +} + +find_browser() { + local c + if [ "$(uname)" = "Darwin" ]; then + for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium" \ + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"; do + [ -x "$c" ] && { echo "$c"; return; } + done + else + for c in google-chrome google-chrome-stable chromium chromium-browser microsoft-edge brave-browser; do + command -v "$c" 2>/dev/null && return + done + fi +} + +start_ui() { + [ "$NO_UI" -eq 1 ] && return + local html="$SCRIPT_DIR/ui.html" py browser port="" i + py="${INSTALL_ROOT:+$INSTALL_ROOT/venv/bin/python3}" + [ -x "${py:-/nonexistent}" ] || py="$(command -v python3 2>/dev/null)" + browser="$(find_browser)" + { [ -f "$html" ] && [ -n "$py" ] && [ -n "$browser" ]; } || { log "shim: no renderer; skipping UI"; return; } + + publish "running" "" + "$py" "$SCRIPT_DIR/serve-ui.py" "$html" "$STATUS" > "$LOG_DIR/desktop-update-ui-port" 2>>"$LOG" & + UI_SERVER_PID=$! + for i in $(seq 1 10); do + port="$(tr -cd '0-9' < "$LOG_DIR/desktop-update-ui-port" 2>/dev/null)" + [ -n "$port" ] && break + sleep 0.2 + done + [ -n "$port" ] || { kill "$UI_SERVER_PID" 2>/dev/null; UI_SERVER_PID=""; return; } + + # Throwaway profile: new window/process we own; user's browser untouched. + "$browser" --app="http://127.0.0.1:$port/" --user-data-dir="${TMPDIR:-/tmp}/hermes-update-ui-$$" \ + --no-first-run --no-default-browser-check --window-size=280,320 >/dev/null 2>&1 & + UI_BROWSER_PID=$! + log "shim: app window on 127.0.0.1:$port" +} + +stop_ui() { # error state leaves the window up for the user to read + if [ -n "$UI_SERVER_PID" ]; then + { kill "$UI_SERVER_PID" && wait "$UI_SERVER_PID"; } 2>/dev/null + fi + if [ "${1:-}" != "leave-window" ] && [ -n "$UI_BROWSER_PID" ]; then + { kill "$UI_BROWSER_PID" && wait "$UI_BROWSER_PID"; } 2>/dev/null + fi + UI_SERVER_PID="" UI_BROWSER_PID="" +} + +# ── 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 + rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true + log "WARNING: bundle copy failed; relaunching existing app" + fi + 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() { + 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 + 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 + 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 ─────────────────────── +if [ "$SELF_TEST_UI" -eq 1 ]; then + start_ui + log "SELF-TEST: shim simulation (no update will run)" + sleep "${HERMES_SELFTEST_HOLD_SECONDS:-6}" + RELAUNCH_TARGET="" + if [ -n "${HERMES_SELFTEST_FAIL:-}" ]; then FINAL_MSG="self-test error state" + else FINAL_CODE=0 FINAL_MSG="self-test complete"; fi + exit "$FINAL_CODE" +fi + +# ── the actual job ────────────────────────────────────────────────────────── +log "hand-off start: root=$INSTALL_ROOT branch=$BRANCH desktopPid=$DESKTOP_PID pid=$$" +rm -f "$RESULT" 2>/dev/null || true +start_ui + +# Marker claim: same cross-process lock contract as windows.ps1 / +# update_lock.py (the `hermes update` child adopts it via process ancestry). +printf '%s\n%s\n' "$$" "$(date +%s)" > "$MARKER" 2>/dev/null || log "WARNING: could not write update marker" + +# Wait out the Desktop (FAIL CLOSED: updating under live backends bricks). +if [ "$DESKTOP_PID" -gt 0 ] 2>/dev/null; then + for _ in $(seq 1 100); do kill -0 "$DESKTOP_PID" 2>/dev/null || break; sleep 0.3; done + if kill -0 "$DESKTOP_PID" 2>/dev/null; then + FINAL_CODE=4 FINAL_MSG="Update aborted: the Hermes window (pid $DESKTOP_PID) did not exit within 30s. Nothing was changed. Close Hermes fully and try again." + log "$FINAL_MSG"; exit "$FINAL_CODE" + fi +fi + +HERMES_BIN="$INSTALL_ROOT/venv/bin/hermes" +[ -x "$HERMES_BIN" ] || { FINAL_CODE=3 FINAL_MSG="Update aborted: $HERMES_BIN is missing. The install needs repair (run the Hermes installer or hermes doctor)."; 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=$? +printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null +log "hermes update exit code: $CODE" + +if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then + # Retry once: update-boundary class (fresh code on disk, stale in memory). + # Exit 2 ("close all Hermes windows") is not retryable. + log "retrying once (freshly pulled fix loads on the second run)" + OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$? + printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null + log "retry exit code: $CODE" +fi + +# Truthful completion: `hermes update` calls a GUI build failure non-fatal +# (exit 0). For a Desktop-driven update that would relaunch the OLD build +# and call it success -- retry the build once, propagate honestly. +if [ "$CODE" -eq 0 ] && printf '%s' "$OUT" | grep -q "Desktop build failed"; then + log "desktop build failed inside hermes update; retrying build" + "$HERMES_BIN" desktop --force-build --build-only >> "$LOG" 2>&1 || { + FINAL_CODE=6 FINAL_MSG="Code and dependencies updated, but the Desktop app rebuild failed - you are running the previous build. Run hermes desktop --force-build from a terminal to retry." + exit 6 + } +fi + +if [ "$CODE" -eq 0 ]; then FINAL_CODE=0 FINAL_MSG="Update complete." +else FINAL_CODE="$CODE" FINAL_MSG="Update failed (exit $CODE). Run hermes debug share in a terminal to send a report."; fi +exit "$FINAL_CODE"