diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 66db2716093b6..5fa1fac0cfa5d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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: { diff --git a/apps/desktop/electron/updater-process.test.ts b/apps/desktop/electron/updater-process.test.ts index 00e2e2b5d5255..dbf3a765f7983 100644 --- a/apps/desktop/electron/updater-process.test.ts +++ b/apps/desktop/electron/updater-process.test.ts @@ -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) +}) diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index f147c1bcd408e..ad6ae0d9f075b 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -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, 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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8fb9062ee3be2..410fb65e2346b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index 524803c003954..a611626438c4d 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -15,26 +15,40 @@ # --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] +# [--relaunch-cwd

] 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] +# [-- ] 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)" diff --git a/scripts/desktop-update/repro.sh b/scripts/desktop-update/repro.sh index 088c5eee66c71..1560d98191a3e 100755 --- a/scripts/desktop-update/repro.sh +++ b/scripts/desktop-update/repro.sh @@ -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 diff --git a/scripts/desktop-update/ui.html b/scripts/desktop-update/ui.html index 5f733ea0d2029..c021f57562c44 100644 --- a/scripts/desktop-update/ui.html +++ b/scripts/desktop-update/ui.html @@ -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'