Merge pull request #83634 from NousResearch/bb/handoff-window
Detached update hand-off on every OS: quit → hermes update → reopen, with one dumb shim window
This commit is contained in:
commit
ed0e707914
|
|
@ -67,3 +67,49 @@ test('malformed JSON is consumed silently', () => {
|
|||
test('absent file returns null', () => {
|
||||
assert.equal(readAndConsumeHandoffResult(tempHome()), null)
|
||||
})
|
||||
|
||||
test('manual flag survives the round trip and defaults false', () => {
|
||||
const home = tempHome()
|
||||
write(home, {
|
||||
ok: true,
|
||||
exit_code: 0,
|
||||
manual: true,
|
||||
message: 'Update complete. Reopen Hermes to finish (it could not restart itself).',
|
||||
branch: 'main',
|
||||
finished_at: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
|
||||
const result = readAndConsumeHandoffResult(home)
|
||||
|
||||
assert.ok(result)
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.manual, true)
|
||||
|
||||
write(home, { ok: true, exit_code: 0, message: 'done', branch: 'main', finished_at: Math.floor(Date.now() / 1000) })
|
||||
assert.equal(readAndConsumeHandoffResult(home)?.manual, false, 'older writers without the field parse as manual:false')
|
||||
})
|
||||
|
||||
test('an old manual result survives the freshness window but an old ordinary one does not', () => {
|
||||
const stale = Math.floor(Date.now() / 1000) - 3600
|
||||
|
||||
const ordinary = tempHome()
|
||||
write(ordinary, { ok: true, exit_code: 0, manual: false, message: 'done', branch: 'main', finished_at: stale })
|
||||
assert.equal(readAndConsumeHandoffResult(ordinary), null, 'a stale ordinary result is discarded')
|
||||
assert.equal(fs.existsSync(handoffResultPath(ordinary)), false, 'and still consumed')
|
||||
|
||||
const home = tempHome()
|
||||
write(home, {
|
||||
ok: true,
|
||||
exit_code: 0,
|
||||
manual: true,
|
||||
message: 'Update complete. Reopen Hermes to finish (it could not restart itself).',
|
||||
branch: 'main',
|
||||
finished_at: stale
|
||||
})
|
||||
|
||||
const result = readAndConsumeHandoffResult(home)
|
||||
|
||||
assert.ok(result, 'a stale manual result is still surfaced — it is the last-resort channel')
|
||||
assert.equal(result.manual, true)
|
||||
assert.equal(readAndConsumeHandoffResult(home), null, 'but only once')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
/**
|
||||
* Consume the detached update hand-off's result file (#82328 follow-up).
|
||||
*
|
||||
* scripts/desktop-update.ps1 runs hidden/detached — the user never sees its
|
||||
* scripts/desktop-update/windows.ps1 runs hidden/detached — the user never sees its
|
||||
* console. It writes HERMES_HOME/.hermes-update-result.json on every exit
|
||||
* path; the relaunched Desktop reads it exactly once on boot and surfaces
|
||||
* failures (a silent failed update looks identical to "nothing happened",
|
||||
* which is how the 2026-08-09 'closed the app then nothing' report was
|
||||
* born). Read-and-delete so a result is reported at most once; results
|
||||
* older than the freshness window are discarded unread (a stale file from a
|
||||
* crashed relaunch chain must not resurface days later).
|
||||
* born). Read-and-delete so a result is reported at most once; ordinary
|
||||
* results older than the freshness window are discarded unread (a stale
|
||||
* file from a crashed relaunch chain must not resurface days later).
|
||||
*
|
||||
* manual:true results are exempt from the freshness window. They are the
|
||||
* durable action-required channel — on a browserless Linux box with no
|
||||
* working notifier, the boot dialog is the FIRST and ONLY place the message
|
||||
* ever surfaces, and the user may not reopen Hermes within 30 minutes.
|
||||
* Dropping it as stale strands exactly the machine it exists to serve. It is
|
||||
* still consumed once (the file is unlinked before any age check), so it
|
||||
* cannot resurface on a later boot.
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
|
|
@ -19,6 +27,11 @@ export const HANDOFF_RESULT_MAX_AGE_MS = 30 * 60 * 1000
|
|||
export interface HandoffResult {
|
||||
ok: boolean
|
||||
exitCode: number
|
||||
/** Update succeeded but the user must act (reopen the app, reinstall the
|
||||
* GUI package, fix the sandbox helper). The consumer must SURFACE these —
|
||||
* an ok:true manual result that only gets logged never reaches the user
|
||||
* on exactly the machines where no shim/notifier could show it live. */
|
||||
manual: boolean
|
||||
message: string
|
||||
branch: string
|
||||
}
|
||||
|
|
@ -56,15 +69,24 @@ export function readAndConsumeHandoffResult(
|
|||
return null
|
||||
}
|
||||
|
||||
const manual = Boolean(parsed?.manual)
|
||||
const finishedAt = Number(parsed?.finished_at)
|
||||
|
||||
if (!Number.isFinite(finishedAt) || now() - finishedAt * 1000 > maxAgeMs) {
|
||||
if (!Number.isFinite(finishedAt)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Ordinary results expire; a manual (action-required) result never does —
|
||||
// it's the last-resort surface for machines with no live channel, so the
|
||||
// user must see it whenever they next reopen, not only within the window.
|
||||
if (!manual && now() - finishedAt * 1000 > maxAgeMs) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
ok: Boolean(parsed?.ok),
|
||||
exitCode: Number.isFinite(Number(parsed?.exit_code)) ? Number(parsed.exit_code) : 1,
|
||||
manual,
|
||||
message: typeof parsed?.message === 'string' ? parsed.message : '',
|
||||
branch: typeof parsed?.branch === 'string' ? parsed.branch : ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,20 +197,13 @@ import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeig
|
|||
import { resolveBehindCount, shouldCountCommits } from './update-count'
|
||||
import { waitForUpdateClearance } from './update-gate'
|
||||
import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker'
|
||||
import { runRebuildWithRetry } from './update-rebuild'
|
||||
import {
|
||||
buildRelaunchScript,
|
||||
collectRelaunchArgs,
|
||||
collectRelaunchEnv,
|
||||
decideRelaunchOutcome,
|
||||
resolveUnpackedRelease,
|
||||
sandboxFallbackFromEnv,
|
||||
sandboxPreflight
|
||||
} from './update-relaunch'
|
||||
import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
|
||||
import {
|
||||
collectRelaunchArgs,
|
||||
resolvePosixScriptHandoff,
|
||||
resolveStagedUpdaterBinary,
|
||||
resolveUpdateScriptHandoff,
|
||||
sandboxFallbackFromEnv,
|
||||
spawnUpdaterProcess,
|
||||
stagedUpdaterSupportsPrewrittenMarker,
|
||||
wrapHandoffForDetachedConsole
|
||||
|
|
@ -1813,7 +1806,7 @@ async function waitForUpdateToFinish() {
|
|||
timeoutMs: UPDATE_WAIT_TIMEOUT_MS
|
||||
})
|
||||
|
||||
// The detached hand-off script (scripts/desktop-update.ps1) runs hidden;
|
||||
// The detached hand-off script (scripts/desktop-update/windows.ps1) runs hidden;
|
||||
// its result file is the ONLY way the user learns a detached update
|
||||
// failed. Consume it exactly once, here, right where boot passes the
|
||||
// update gate — success gets a log line, failure gets a real dialog
|
||||
|
|
@ -1822,7 +1815,18 @@ async function waitForUpdateToFinish() {
|
|||
try {
|
||||
const result = readAndConsumeHandoffResult(HERMES_HOME)
|
||||
|
||||
if (result && result.ok) {
|
||||
if (result && result.ok && result.manual) {
|
||||
// Update landed but the user must act (reopen/reinstall/sandbox). On
|
||||
// machines with no shim browser and no notifier this dialog is the
|
||||
// FIRST time the message is visible — it must not be a log line.
|
||||
rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`)
|
||||
dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'Hermes update',
|
||||
message: 'The update finished, but needs one more step',
|
||||
detail: result.message
|
||||
})
|
||||
} else if (result && result.ok) {
|
||||
rememberLog(`[updates] detached update finished OK (branch ${result.branch})`)
|
||||
} else if (result) {
|
||||
rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`)
|
||||
|
|
@ -2868,14 +2872,16 @@ async function applyUpdates(opts = {}) {
|
|||
const updater = resolveUpdaterBinary()
|
||||
|
||||
if (!updater && !IS_WINDOWS) {
|
||||
// macOS/Linux: never hand off, staged hermes-setup or not — the resolver
|
||||
// returns null there by policy. Unlike Windows (where a venv-shim file
|
||||
// lock forces the quit→hand-off→rebuild dance), there's no mandatory file
|
||||
// locking here, so the desktop can drive the whole update itself:
|
||||
// `hermes update` (backend) + `hermes desktop --build-only` (OS-aware GUI
|
||||
// rebuild), then swap the running .app bundle with the freshly built one
|
||||
// and relaunch.
|
||||
return await applyUpdatesPosixInApp(opts)
|
||||
// macOS/Linux: hand off to the repo-owned posix script — same shape as
|
||||
// Windows (quit → detached orchestrator → `hermes update` → relaunch),
|
||||
// minus the venv-lock gauntlet POSIX doesn't need. The old in-app
|
||||
// updater (applyUpdatesPosixInApp) is gone with everything it dragged
|
||||
// in: the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance (#37532),
|
||||
// the in-window rebuild retry, and the relaunch-outcome matrix — the
|
||||
// script owns swap/relaunch, and the app is DEAD during the update so
|
||||
// there is nothing to reap around. Checkouts that predate the script
|
||||
// get the manual `hermes update` card once; their next update pulls it.
|
||||
return await applyUpdatesPosixHandoff(opts)
|
||||
}
|
||||
|
||||
if (!updater) {
|
||||
|
|
@ -3022,7 +3028,7 @@ async function applyUpdates(opts = {}) {
|
|||
// The staged binary is frozen (no self-update path) and historically runs
|
||||
// months-stale updater logic — pre-#67369 cache resolver, pre-#74782
|
||||
// marker adoption — producing failures that were fixed on main long ago
|
||||
// (2026-08-09 incident). scripts/desktop-update.ps1 ships WITH the
|
||||
// (2026-08-09 incident). scripts/desktop-update/windows.ps1 ships WITH the
|
||||
// checkout, so each `hermes update` refreshes the code that drives the
|
||||
// next one. Checkouts that predate the script fall back to the binary
|
||||
// path unchanged.
|
||||
|
|
@ -3220,56 +3226,6 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
|||
return true
|
||||
}
|
||||
|
||||
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
|
||||
// the install we're updating, fall back to `hermes` on PATH.
|
||||
function resolveHermesCliBinary(updateRoot) {
|
||||
const venvHermes = path.join(updateRoot, 'venv', 'bin', 'hermes')
|
||||
|
||||
if (fileExists(venvHermes)) {
|
||||
return venvHermes
|
||||
}
|
||||
|
||||
return findOnPath('hermes') || null
|
||||
}
|
||||
|
||||
// Spawn a command and stream each output line to the update progress channel.
|
||||
function runStreamedUpdate(command, args, { cwd, env, stage }: any = {}) {
|
||||
return new Promise(resolve => {
|
||||
let child
|
||||
|
||||
try {
|
||||
child = spawn(
|
||||
command,
|
||||
args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
resolve({ code: 1, error: err.message })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const emitLines = chunk => {
|
||||
for (const line of chunk.toString().split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed) {
|
||||
emitUpdateProgress({ stage, message: trimmed, percent: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on('data', emitLines)
|
||||
child.stderr.on('data', emitLines)
|
||||
child.once('error', err => resolve({ code: 1, error: err.message }))
|
||||
child.once('exit', code => resolve({ code }))
|
||||
})
|
||||
}
|
||||
|
||||
// The running app's .app bundle (packaged macOS): execPath is
|
||||
// <App>.app/Contents/MacOS/<exe>; climb three levels to the bundle root.
|
||||
function runningAppBundle() {
|
||||
|
|
@ -3372,307 +3328,124 @@ function preflightStateDb(hermesHome, rememberLog) {
|
|||
}
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
// macOS/Linux in-app update: backend (`hermes update`) + OS-aware GUI rebuild
|
||||
// (`hermes desktop --build-only`), then atomically swap the running .app bundle
|
||||
// with the freshly built one and relaunch. Degrades to "backend updated,
|
||||
// restart to load the new GUI" if the swap can't be performed.
|
||||
async function applyUpdatesPosixInApp(opts: any) {
|
||||
// macOS/Linux update hand-off: spawn the repo-owned posix orchestrator
|
||||
// (scripts/desktop-update/posix.sh) detached and QUIT. The script waits us
|
||||
// out, runs `hermes update`, swaps/relaunches the app bundle, and writes
|
||||
// .hermes-update-result.json for the relaunched Desktop to surface. It shows
|
||||
// its own tiny shim window (or nothing, headless) — this process only needs
|
||||
// to leave. Checkouts that predate the script get the manual card once.
|
||||
async function applyUpdatesPosixHandoff(opts: any) {
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const hermes = resolveHermesCliBinary(updateRoot)
|
||||
const handoff = resolvePosixScriptHandoff(updateRoot)
|
||||
|
||||
if (!hermes) {
|
||||
if (!handoff) {
|
||||
emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null })
|
||||
|
||||
return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot }
|
||||
}
|
||||
|
||||
const handoffConflict = updateHandoffConflict(HERMES_HOME)
|
||||
|
||||
if (handoffConflict) {
|
||||
// Same hazard as the Windows path (#75778): a live foreign updater
|
||||
// already owns the marker — refuse rather than double-mutate the tree.
|
||||
rememberLog(`[updates] refusing posix hand-off: ${handoffConflict.message}`)
|
||||
emitUpdateProgress({ stage: 'error', message: handoffConflict.message, percent: null })
|
||||
|
||||
return { ok: false, error: 'update-already-running', message: handoffConflict.message }
|
||||
}
|
||||
|
||||
// ── Pre-flight state.db integrity guard (#68474) ──
|
||||
preflightStateDb(HERMES_HOME, rememberLog)
|
||||
|
||||
// Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s
|
||||
// npm build can find them on a machine with no system Node. Windows portable
|
||||
// Node lives directly under %LOCALAPPDATA%\\hermes\\node, not node\\bin.
|
||||
// PYTHONUNBUFFERED: `hermes update` writes to a pipe here, so CPython
|
||||
// block-buffers stdout and long quiet steps (the pre-update backup can zip
|
||||
// multi-GB archives for minutes) stream nothing to the progress UI — users
|
||||
// read the silence as a hang and cancel a healthy update.
|
||||
const env: Record<string, string> = {
|
||||
HERMES_HOME,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin'))
|
||||
}
|
||||
|
||||
// `hermes update` reaps stale `hermes serve` backends (a code update
|
||||
// leaves the running process serving old Python against the freshly-updated
|
||||
// JS bundle). But OUR backend is one of those processes, and killing it
|
||||
// mid-update produces the boot→kill→crash loop in #37532 — the desktop
|
||||
// already restarts its own backend via the rebuild+relaunch below, so the
|
||||
// reap must spare it. Hand the live backend's PID to the update process;
|
||||
// _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
|
||||
// it while still reaping any genuinely-orphaned backends. (#37532)
|
||||
// Exclude every desktop-managed backend (primary + all pool profiles) from
|
||||
// the update reaper. _kill_stale_dashboard_processes accepts a comma-separated
|
||||
// list (a single int still parses for back-compat).
|
||||
const desktopChildPids = []
|
||||
const hermesProcess = backendConnectionState.getProcess()
|
||||
|
||||
if (hermesProcess && Number.isInteger(hermesProcess.pid)) {
|
||||
desktopChildPids.push(hermesProcess.pid)
|
||||
}
|
||||
|
||||
for (const entry of backendPool.values()) {
|
||||
if (entry.process && Number.isInteger(entry.process.pid)) {
|
||||
desktopChildPids.push(entry.process.pid)
|
||||
}
|
||||
}
|
||||
|
||||
if (desktopChildPids.length) {
|
||||
env.HERMES_DESKTOP_CHILD_PID = desktopChildPids.join(',')
|
||||
}
|
||||
|
||||
// Branch-pin so a non-main checkout doesn't get switched to main (and self-heal
|
||||
// to main when the pinned branch no longer exists on origin).
|
||||
let branchArgs = []
|
||||
// Branch-pin so a non-main checkout doesn't get switched to main (and
|
||||
// self-heal to main when the pinned branch no longer exists on origin).
|
||||
let branch = 'main'
|
||||
|
||||
try {
|
||||
const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot })
|
||||
const current = (head.stdout || '').trim()
|
||||
|
||||
if (head.code === 0 && current && current !== 'HEAD') {
|
||||
branchArgs = ['--branch', await resolveHealedBranch(updateRoot, current)]
|
||||
branch = await resolveHealedBranch(updateRoot, current)
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
|
||||
emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 })
|
||||
const args = [
|
||||
...handoff.args,
|
||||
'--install-root',
|
||||
updateRoot,
|
||||
'--branch',
|
||||
branch,
|
||||
'--desktop-pid',
|
||||
String(process.pid)
|
||||
]
|
||||
|
||||
const updated = (await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], {
|
||||
cwd: updateRoot,
|
||||
env,
|
||||
stage: 'update'
|
||||
})) as any
|
||||
// Relaunch target: the running .app bundle on mac (script swaps the
|
||||
// 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 (updated.code !== 0) {
|
||||
emitUpdateProgress({ stage: 'error', message: 'hermes update failed.', error: updated.error || 'update-failed' })
|
||||
|
||||
return { ok: false, error: 'hermes update failed' }
|
||||
if (targetApp) {
|
||||
args.push('--relaunch-target', targetApp)
|
||||
}
|
||||
|
||||
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
|
||||
const relaunchArgs = collectRelaunchArgs(process.argv.slice(1))
|
||||
|
||||
// Retry-once: a first rebuild can fail on a still-settling tree or a
|
||||
// self-healed (network-blocked) Electron download; a second run builds clean
|
||||
// off the healed dist so we reach the swap+relaunch below instead of bailing.
|
||||
const rebuilt = await runRebuildWithRetry(attempt => {
|
||||
if (attempt > 0) {
|
||||
emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 })
|
||||
if (!IS_MAC) {
|
||||
args.push('--relaunch-cwd', process.cwd())
|
||||
|
||||
if (sandboxFallbackFromEnv(process.env, relaunchArgs)) {
|
||||
args.push('--sandbox-fallback')
|
||||
}
|
||||
|
||||
return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' })
|
||||
if (relaunchArgs.length) {
|
||||
args.push('--', ...relaunchArgs)
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawnUpdaterProcess(handoff.command, args, {
|
||||
cwd: HERMES_HOME,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin'))
|
||||
},
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
|
||||
if (rebuilt.code !== 0) {
|
||||
emitUpdateProgress({
|
||||
stage: 'error',
|
||||
message: 'Backend updated, but the desktop rebuild failed. Restart Hermes to retry.',
|
||||
error: rebuilt.error || 'rebuild-failed'
|
||||
})
|
||||
|
||||
return { ok: false, backendUpdated: true, error: 'desktop rebuild failed' }
|
||||
// Bridge marker (same contract as the Windows hand-off): cover the gap
|
||||
// until the script claims the marker with its own pid as step 0. If the
|
||||
// script never starts, the dead pid reads as stale and self-deletes.
|
||||
if (Number.isInteger(child.pid)) {
|
||||
writeUpdateMarker(HERMES_HOME, child.pid)
|
||||
}
|
||||
|
||||
// Linux in-app update terminal state (#45205). `hermes desktop --build-only`
|
||||
// rebuilds the unpacked app in place under apps/desktop/release/<plat>-unpacked.
|
||||
// We can only HONESTLY relaunch into the new GUI when the *running* binary IS
|
||||
// that rebuilt one — i.e. execPath lives under release/<plat>-unpacked. The
|
||||
// outcome is decided by three signals (see update-relaunch.ts):
|
||||
//
|
||||
// underUnpacked + sandboxOk → 'relaunch': detached watcher re-execs us in
|
||||
// place (mirrors the macOS handoff). Without it the update succeeds but
|
||||
// the app never restarts and the overlay hangs on "applying" forever.
|
||||
// !underUnpacked → 'guiSkew': the running shell is an AppImage/
|
||||
// .deb/.rpm/dev/unresolved binary we did NOT replace. Claiming "loads
|
||||
// next launch" is a lie (GUI/backend skew, #37541) — surface an
|
||||
// explicit closeable terminal state telling the user the GUI package
|
||||
// was NOT changed and must be updated/reinstalled.
|
||||
// underUnpacked + !sandboxOk → 'manual': we'd be relaunching the rebuilt
|
||||
// binary, but a fresh rebuild can leave chrome-sandbox without
|
||||
// root:root + setuid (mode 4755) and Electron then refuses to launch
|
||||
// ("quit and never came back"). DO NOT quit into a dead app — keep the
|
||||
// working window and surface the closeable manual-restart state.
|
||||
if (!IS_MAC) {
|
||||
const unpackedDir = resolveUnpackedRelease(process.execPath, updateRoot, process.platform)
|
||||
const underUnpacked = unpackedDir !== null
|
||||
|
||||
const preflight = underUnpacked
|
||||
? sandboxPreflight(unpackedDir, p => fs.statSync(p))
|
||||
: { ok: false, reason: 'not-under-unpacked', path: null }
|
||||
|
||||
const sandboxFallback = sandboxFallbackFromEnv(process.env, process.argv.slice(1))
|
||||
const sandboxOk = preflight.ok || sandboxFallback
|
||||
|
||||
if (underUnpacked && !preflight.ok) {
|
||||
rememberLog(
|
||||
`[updates] sandbox preflight: not launchable (${preflight.reason}) at ${preflight.path}; ` +
|
||||
`fallback=${sandboxFallback ? 'env/--no-sandbox' : 'none'}`
|
||||
)
|
||||
}
|
||||
|
||||
const outcome = decideRelaunchOutcome({ underUnpacked, sandboxOk })
|
||||
|
||||
if (outcome === 'relaunch') {
|
||||
emitUpdateProgress({ stage: 'restart', message: 'Restarting Hermes…', percent: 100 })
|
||||
// Preserve launch context across the re-exec: replay the original args
|
||||
// (filtered of Electron internals) and the env/cwd that define which
|
||||
// backend/profile/root this instance talks to. Without this the
|
||||
// relaunched instance comes up with default context instead of the user's.
|
||||
const relaunchArgs = collectRelaunchArgs(process.argv.slice(1))
|
||||
const relaunchEnv = collectRelaunchEnv(process.env)
|
||||
|
||||
const relaunchScript = buildRelaunchScript({
|
||||
pid: process.pid,
|
||||
execPath: process.execPath,
|
||||
args: relaunchArgs,
|
||||
env: relaunchEnv,
|
||||
cwd: process.cwd()
|
||||
})
|
||||
|
||||
const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`)
|
||||
|
||||
try {
|
||||
fs.writeFileSync(scriptPath, relaunchScript, { mode: 0o755 })
|
||||
const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' })
|
||||
child.unref()
|
||||
rememberLog(
|
||||
`[updates] launched linux relaunch: ${scriptPath} -> ${process.execPath} ` +
|
||||
`(args=${relaunchArgs.length}, env=${Object.keys(relaunchEnv).length})`
|
||||
)
|
||||
isQuittingForHandoff = true
|
||||
setTimeout(() => app.quit(), UPDATE_HANDOFF_DWELL_MS)
|
||||
|
||||
return { ok: true, handedOff: true }
|
||||
} catch (err) {
|
||||
rememberLog(`[updates] linux relaunch failed: ${err.message}; falling back to manual restart`)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
backendUpdated: true,
|
||||
guiUpdated: false,
|
||||
manualRestart: true,
|
||||
message: 'Backend updated. Quit and reopen Hermes to load the new version.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (outcome === 'guiSkew') {
|
||||
emitUpdateProgress({
|
||||
stage: 'guiSkew',
|
||||
message:
|
||||
'Backend updated, but the desktop app package was not changed. ' +
|
||||
'Update or reinstall the Hermes desktop app to match.',
|
||||
percent: 100
|
||||
})
|
||||
rememberLog(
|
||||
`[updates] gui/backend skew: execPath ${process.execPath} not under release/*-unpacked; ` +
|
||||
'backend updated, GUI package unchanged (AppImage/.deb/.rpm/dev/unresolved)'
|
||||
)
|
||||
|
||||
return { ok: true, backendUpdated: true, guiUpdated: false, guiSkew: true }
|
||||
}
|
||||
|
||||
// outcome === 'manual': we're the rebuilt binary, but its sandbox helper is
|
||||
// not launchable and no fallback applies. Keep this working window alive.
|
||||
rememberLog(
|
||||
`[updates] sandbox not launchable (${preflight.reason}); skipping auto-relaunch, ` +
|
||||
'returning manual-restart so the user keeps a working window'
|
||||
)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
backendUpdated: true,
|
||||
guiUpdated: false,
|
||||
manualRestart: true,
|
||||
sandboxBlocked: true,
|
||||
message:
|
||||
'Backend updated. The rebuilt app can’t relaunch automatically ' +
|
||||
'(sandbox helper needs root). Quit and reopen Hermes to finish.'
|
||||
}
|
||||
}
|
||||
|
||||
const rebuiltApp = [
|
||||
path.join(updateRoot, 'apps', 'desktop', 'release', 'mac-arm64', 'Hermes.app'),
|
||||
path.join(updateRoot, 'apps', 'desktop', 'release', 'mac', 'Hermes.app')
|
||||
].find(directoryExists)
|
||||
|
||||
const targetApp = runningAppBundle()
|
||||
|
||||
// No bundle to swap (dev run, Linux AppImage, or unresolved paths): the
|
||||
// backend is updated; the next launch picks up the rebuilt GUI.
|
||||
if (!rebuiltApp || !targetApp) {
|
||||
emitUpdateProgress({
|
||||
stage: 'done',
|
||||
message: 'Backend updated. Restart Hermes to load the new version.',
|
||||
percent: 100
|
||||
})
|
||||
|
||||
return { ok: true, backendUpdated: true, rebuiltApp: rebuiltApp || null }
|
||||
}
|
||||
|
||||
emitUpdateProgress({ stage: 'restart', message: 'Installing the updated app and restarting…', percent: 95 })
|
||||
|
||||
// Detached swapper: wait for THIS process to exit (so the bundle is free),
|
||||
// ditto the rebuilt app over the running one, clear quarantine, relaunch.
|
||||
const swapScript = `#!/bin/bash
|
||||
set -u
|
||||
APP_PID=${process.pid}
|
||||
SRC=${shellQuote(rebuiltApp)}
|
||||
DST=${shellQuote(targetApp)}
|
||||
for _ in $(seq 1 240); do
|
||||
kill -0 "$APP_PID" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
if [ "$SRC" != "$DST" ]; then
|
||||
if /usr/bin/ditto "$SRC" "$DST.hermes-update-new"; then
|
||||
rm -rf "$DST.hermes-update-old" 2>/dev/null || true
|
||||
mv "$DST" "$DST.hermes-update-old" 2>/dev/null || rm -rf "$DST"
|
||||
mv "$DST.hermes-update-new" "$DST"
|
||||
rm -rf "$DST.hermes-update-old" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
/usr/bin/xattr -dr com.apple.quarantine "$DST" 2>/dev/null || true
|
||||
/usr/bin/open "$DST"
|
||||
`
|
||||
|
||||
const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`)
|
||||
|
||||
try {
|
||||
fs.writeFileSync(scriptPath, swapScript, { mode: 0o755 })
|
||||
} catch (err) {
|
||||
emitUpdateProgress({
|
||||
stage: 'done',
|
||||
message: 'Backend + app updated. Restart Hermes to load the new version.',
|
||||
percent: 100
|
||||
})
|
||||
rememberLog(`[updates] could not write swap script: ${err.message}; rebuilt app at ${rebuiltApp}`)
|
||||
|
||||
return { ok: true, backendUpdated: true, rebuiltApp }
|
||||
}
|
||||
|
||||
const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' })
|
||||
child.unref()
|
||||
rememberLog(`[updates] launched mac swap+relaunch: ${scriptPath} (${rebuiltApp} -> ${targetApp})`)
|
||||
rememberLog(
|
||||
`[updates] launched posix hand-off: ${handoff.scriptPath} (branch ${branch}); quitting to hand off`
|
||||
)
|
||||
emitUpdateProgress({
|
||||
stage: 'restart',
|
||||
message:
|
||||
'Updating Hermes — this window will close. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.',
|
||||
percent: 100
|
||||
})
|
||||
|
||||
isQuittingForHandoff = true
|
||||
setTimeout(() => app.quit(), 600)
|
||||
setTimeout(() => {
|
||||
app.quit()
|
||||
}, UPDATE_HANDOFF_DWELL_MS)
|
||||
|
||||
return { ok: true, handedOff: true, rebuiltApp, targetApp }
|
||||
return { ok: true, handedOff: true, updater: handoff.scriptPath }
|
||||
}
|
||||
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
/**
|
||||
* Tests for electron/update-rebuild.ts — the retry-once policy for the desktop
|
||||
* `--build-only` rebuild during self-update.
|
||||
*
|
||||
* Run with: node --test electron/update-rebuild.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Why this matters: a first rebuild can return nonzero on a still-settling tree
|
||||
* or a self-healed (network-blocked) Electron download. Without a second attempt
|
||||
* the updater bails before the relaunch step — the app updates but never restarts
|
||||
* (the field report behind this fix). The retry must fire on failure, not on
|
||||
* success, and must run at most twice.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild'
|
||||
|
||||
test('shouldRetryRebuild retries only on a non-success exit', () => {
|
||||
assert.equal(shouldRetryRebuild(0), false)
|
||||
assert.equal(shouldRetryRebuild(1), true)
|
||||
assert.equal(shouldRetryRebuild(null), true)
|
||||
})
|
||||
|
||||
test('a clean first rebuild runs once and does not retry', async () => {
|
||||
const codes = []
|
||||
|
||||
const result = await runRebuildWithRetry(attempt => {
|
||||
codes.push(attempt)
|
||||
|
||||
return Promise.resolve({ code: 0 })
|
||||
})
|
||||
|
||||
assert.deepEqual(codes, [0])
|
||||
assert.equal(result.code, 0)
|
||||
})
|
||||
|
||||
test('a failed first rebuild retries once and succeeds', async () => {
|
||||
const codes = []
|
||||
|
||||
const result = await runRebuildWithRetry(attempt => {
|
||||
codes.push(attempt)
|
||||
|
||||
return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
|
||||
})
|
||||
|
||||
assert.deepEqual(codes, [0, 1])
|
||||
assert.equal(result.code, 0)
|
||||
})
|
||||
|
||||
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
|
||||
const codes = []
|
||||
|
||||
const result = await runRebuildWithRetry(attempt => {
|
||||
codes.push(attempt)
|
||||
|
||||
return Promise.resolve({ code: 1, error: 'rebuild-failed' })
|
||||
})
|
||||
|
||||
assert.deepEqual(codes, [0, 1])
|
||||
assert.equal(result.code, 1)
|
||||
assert.equal(result.error, 'rebuild-failed')
|
||||
})
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/**
|
||||
* Retry-once policy for the desktop `--build-only` rebuild during self-update.
|
||||
*
|
||||
* The first rebuild can return nonzero on a still-settling post-update tree or a
|
||||
* network-blocked Electron fetch that the installer's self-heal repaired mid-run.
|
||||
* A second attempt then builds clean off the healed dist (the content-hash stamp
|
||||
* makes it a near-no-op when the first actually succeeded). Without the retry the
|
||||
* updater bails before the relaunch step — the app updates but doesn't restart.
|
||||
*/
|
||||
|
||||
function shouldRetryRebuild(code) {
|
||||
return code !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `rebuild()` (async, resolves `{ code, ... }`), retrying once on failure.
|
||||
* Returns the final result.
|
||||
*/
|
||||
async function runRebuildWithRetry(rebuild) {
|
||||
let result = await rebuild(0)
|
||||
|
||||
if (shouldRetryRebuild(result.code)) {
|
||||
result = await rebuild(1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export { runRebuildWithRetry, shouldRetryRebuild }
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
/**
|
||||
* Tests for electron/update-relaunch.ts — the pure decision + script helpers
|
||||
* behind the Linux in-app update relaunch (#45205).
|
||||
*
|
||||
* Run with: node --test electron/update-relaunch.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* What this locks (review acceptance criteria for PR #45205):
|
||||
* 1. The execPath split: only a binary under release/<plat>-unpacked may
|
||||
* relaunch/claim a GUI update; AppImage/.deb/.rpm/dev/unresolved paths land
|
||||
* on the guiSkew terminal state and do NOT claim the GUI was updated.
|
||||
* 2. Launch context is replayed on re-exec (args filtered of Electron
|
||||
* internals; HERMES_HOME / HERMES_DESKTOP_* env + cwd preserved) and is
|
||||
* safely shell-quoted.
|
||||
* 3. The sandbox preflight: chrome-sandbox must be root-owned + setuid to be
|
||||
* launchable; otherwise the decision degrades to a manual terminal state
|
||||
* (keep a working window) unless a non-interactive fallback applies.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildRelaunchScript,
|
||||
collectRelaunchArgs,
|
||||
collectRelaunchEnv,
|
||||
decideRelaunchOutcome,
|
||||
resolveUnpackedRelease,
|
||||
sandboxFallbackFromEnv,
|
||||
sandboxPreflight,
|
||||
shellQuote,
|
||||
unpackedDirName
|
||||
} from './update-relaunch'
|
||||
|
||||
const ROOT = '/home/u/.hermes/hermes-agent'
|
||||
const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1) The execPath split — the heart of the GUI/backend skew guard.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('unpackedDirName maps platform to the electron-builder dir', () => {
|
||||
assert.equal(unpackedDirName('linux'), 'linux-unpacked')
|
||||
assert.equal(unpackedDirName('win32'), 'win-unpacked')
|
||||
})
|
||||
|
||||
test('resolveUnpackedRelease returns the dir for a binary UNDER release/<plat>-unpacked', () => {
|
||||
const exec = path.join(UNPACKED, 'hermes')
|
||||
assert.equal(resolveUnpackedRelease(exec, ROOT, 'linux'), UNPACKED)
|
||||
// The unpacked dir itself also counts.
|
||||
assert.equal(resolveUnpackedRelease(UNPACKED, ROOT, 'linux'), UNPACKED)
|
||||
})
|
||||
|
||||
test('resolveUnpackedRelease is null for AppImage / .deb / .rpm / dev / unresolved paths', () => {
|
||||
// AppImage mount
|
||||
assert.equal(resolveUnpackedRelease('/tmp/.mount_Hermes12345/AppRun', ROOT, 'linux'), null)
|
||||
// .deb / .rpm system install
|
||||
assert.equal(resolveUnpackedRelease('/usr/lib/hermes/hermes', ROOT, 'linux'), null)
|
||||
assert.equal(resolveUnpackedRelease('/opt/Hermes/hermes', ROOT, 'linux'), null)
|
||||
// dev electron
|
||||
assert.equal(
|
||||
resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'),
|
||||
null
|
||||
)
|
||||
// empty / missing
|
||||
assert.equal(resolveUnpackedRelease('', ROOT, 'linux'), null)
|
||||
assert.equal(resolveUnpackedRelease(path.join(UNPACKED, 'hermes'), '', 'linux'), null)
|
||||
})
|
||||
|
||||
test('resolveUnpackedRelease is not fooled by a sibling prefix dir', () => {
|
||||
// `.../release/linux-unpacked-evil` must NOT match `.../release/linux-unpacked`.
|
||||
const sneaky = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked-evil', 'hermes')
|
||||
assert.equal(resolveUnpackedRelease(sneaky, ROOT, 'linux'), null)
|
||||
})
|
||||
|
||||
test('decideRelaunchOutcome: only under-unpacked + sandbox-ok relaunches', () => {
|
||||
assert.equal(decideRelaunchOutcome({ underUnpacked: true, sandboxOk: true }), 'relaunch')
|
||||
// Under unpacked but sandbox not launchable → manual (keep a working window).
|
||||
assert.equal(decideRelaunchOutcome({ underUnpacked: true, sandboxOk: false }), 'manual')
|
||||
// Not under unpacked → guiSkew regardless of sandbox flag.
|
||||
assert.equal(decideRelaunchOutcome({ underUnpacked: false, sandboxOk: true }), 'guiSkew')
|
||||
assert.equal(decideRelaunchOutcome({ underUnpacked: false, sandboxOk: false }), 'guiSkew')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3) Sandbox preflight
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fakeStat = (uid, mode) => () => ({ uid, mode })
|
||||
|
||||
const throwStat = () => {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
test('sandboxPreflight: root-owned + setuid is launchable', () => {
|
||||
const r = sandboxPreflight(UNPACKED, fakeStat(0, 0o4755))
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.reason, 'launchable')
|
||||
})
|
||||
|
||||
test('sandboxPreflight: not root → not launchable', () => {
|
||||
const r = sandboxPreflight(UNPACKED, fakeStat(1000, 0o4755))
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.reason, 'not-root')
|
||||
})
|
||||
|
||||
test('sandboxPreflight: missing setuid bit → not launchable', () => {
|
||||
const r = sandboxPreflight(UNPACKED, fakeStat(0, 0o755))
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.reason, 'not-setuid')
|
||||
})
|
||||
|
||||
test('sandboxPreflight: neither root nor setuid (the fresh-rebuild trap)', () => {
|
||||
const r = sandboxPreflight(UNPACKED, fakeStat(1000, 0o755))
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.reason, 'not-root-not-setuid')
|
||||
})
|
||||
|
||||
test('sandboxPreflight: no chrome-sandbox helper present → ok (build does not use SUID sandbox)', () => {
|
||||
const r = sandboxPreflight(UNPACKED, throwStat)
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.reason, 'no-sandbox-helper')
|
||||
})
|
||||
|
||||
test('sandboxFallbackFromEnv: ELECTRON_DISABLE_SANDBOX / --no-sandbox make a broken sandbox safe', () => {
|
||||
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({}, ['--foo']), false)
|
||||
assert.equal(sandboxFallbackFromEnv({}, []), false)
|
||||
assert.equal(sandboxFallbackFromEnv(null, null), false)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2) Launch-context preservation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', () => {
|
||||
const argv = [
|
||||
'--type=renderer',
|
||||
'--user-data-dir=/tmp/x',
|
||||
'--enable-features=Foo',
|
||||
'--field-trial-handle=123',
|
||||
'--no-sandbox', // sandbox opt-out — KEEP (user/env intent + relaunch fallback)
|
||||
'--lang=en-US',
|
||||
'hermes://open/agent/42', // deep link — keep
|
||||
'--profile=work', // app flag — keep
|
||||
'--remote-debugging-port=9222' // internal — drop
|
||||
]
|
||||
|
||||
assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work'])
|
||||
assert.deepEqual(collectRelaunchArgs(undefined), [])
|
||||
})
|
||||
|
||||
test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt-out only', () => {
|
||||
const env = {
|
||||
HERMES_HOME: '/home/u/.hermes',
|
||||
HERMES_DESKTOP_REMOTE_URL: 'http://box:9119',
|
||||
HERMES_DESKTOP_REMOTE_TOKEN: 'secret',
|
||||
HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes',
|
||||
HERMES_DESKTOP_APP_NAME: 'HermesSandbox',
|
||||
ELECTRON_DISABLE_SANDBOX: '1', // sandbox opt-out — preserved
|
||||
PATH: '/usr/bin', // not preserved
|
||||
HOME: '/home/u', // not preserved
|
||||
UNRELATED: 'x'
|
||||
}
|
||||
|
||||
assert.deepEqual(collectRelaunchEnv(env), {
|
||||
HERMES_HOME: '/home/u/.hermes',
|
||||
HERMES_DESKTOP_REMOTE_URL: 'http://box:9119',
|
||||
HERMES_DESKTOP_REMOTE_TOKEN: 'secret',
|
||||
HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes',
|
||||
HERMES_DESKTOP_APP_NAME: 'HermesSandbox',
|
||||
ELECTRON_DISABLE_SANDBOX: '1'
|
||||
})
|
||||
assert.deepEqual(collectRelaunchEnv(null), {})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generated watcher script: safe quoting + valid bash syntax.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('shellQuote neutralizes single quotes and metacharacters', () => {
|
||||
assert.equal(shellQuote(`a'b`), `'a'\\''b'`)
|
||||
assert.equal(shellQuote('$(rm -rf /)'), `'$(rm -rf /)'`)
|
||||
})
|
||||
|
||||
test('buildRelaunchScript embeds pid/exec/args/env/cwd and is valid bash', () => {
|
||||
const script = buildRelaunchScript({
|
||||
pid: 4242,
|
||||
execPath: '/home/u/.hermes/hermes-agent/apps/desktop/release/linux-unpacked/Hermes',
|
||||
args: ['hermes://open/agent/42', "--note=it's fine"],
|
||||
env: { HERMES_HOME: '/home/u/.hermes', HERMES_DESKTOP_REMOTE_URL: 'http://box:9119' },
|
||||
cwd: '/home/u/work dir'
|
||||
})
|
||||
|
||||
// Structural assertions.
|
||||
assert.match(script, /^#!\/bin\/bash/)
|
||||
assert.match(script, /APP_PID=4242/)
|
||||
assert.match(script, /kill -9 "\$APP_PID"/)
|
||||
assert.match(script, /rm -f -- "\$0"/)
|
||||
// env exports + cwd restore + args replay are present and quoted.
|
||||
assert.match(script, /export HERMES_HOME='\/home\/u\/\.hermes'/)
|
||||
assert.match(script, /export HERMES_DESKTOP_REMOTE_URL='http:\/\/box:9119'/)
|
||||
assert.match(script, /cd '\/home\/u\/work dir'/)
|
||||
assert.match(script, /exec '.*\/linux-unpacked\/Hermes' 'hermes:\/\/open\/agent\/42' '--note=it'\\''s fine'/)
|
||||
|
||||
// It must be syntactically valid bash (`bash -n`). Write to a temp file and lint.
|
||||
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`)
|
||||
fs.writeFileSync(tmp, script)
|
||||
|
||||
try {
|
||||
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
|
||||
} finally {
|
||||
fs.rmSync(tmp, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('buildRelaunchScript with no args/env still lints clean', () => {
|
||||
const script = buildRelaunchScript({
|
||||
pid: 1,
|
||||
execPath: '/opt/Hermes/Hermes',
|
||||
args: [],
|
||||
env: {},
|
||||
cwd: ''
|
||||
})
|
||||
|
||||
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`)
|
||||
fs.writeFileSync(tmp, script)
|
||||
|
||||
try {
|
||||
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
|
||||
} finally {
|
||||
fs.rmSync(tmp, { force: true })
|
||||
}
|
||||
|
||||
// exec line has no trailing args.
|
||||
assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/)
|
||||
})
|
||||
|
|
@ -1,314 +0,0 @@
|
|||
/**
|
||||
* update-relaunch.ts — pure decision + script-generation helpers for the
|
||||
* Linux in-app update relaunch (#45205).
|
||||
*
|
||||
* Extracted from main.ts's `applyUpdatesPosixInApp` so the security- and
|
||||
* correctness-critical "do we relaunch, or land on a manual terminal state?"
|
||||
* decision is unit-testable without booting Electron (main.ts
|
||||
* `require('electron')` at load).
|
||||
*
|
||||
* Background
|
||||
* ----------
|
||||
* After `hermes update` + `hermes desktop --build-only`, the freshly-rebuilt
|
||||
* GUI lives under `apps/desktop/release/<plat>-unpacked`. We can only honestly
|
||||
* relaunch into the new GUI when the *running* binary is that rebuilt one —
|
||||
* i.e. its execPath is under the rebuilt `release/<plat>-unpacked` dir.
|
||||
*
|
||||
* - Source / unpacked install (execPath under release/<plat>-unpacked):
|
||||
* the running binary IS the thing we just rebuilt → relaunch it in place.
|
||||
* - AppImage / .deb / .rpm / dev / unresolved (execPath elsewhere):
|
||||
* the backend was updated but THIS GUI shell was NOT replaced. Claiming
|
||||
* "the new version loads next launch" is a lie that produces GUI/backend
|
||||
* skew (#37541): the user keeps running the old GUI against new backend
|
||||
* code with no path to fix it from inside the app. Surface an explicit
|
||||
* terminal state telling them the GUI package must be reinstalled.
|
||||
*
|
||||
* Sandbox preflight (#3 in the review)
|
||||
* ------------------------------------
|
||||
* A fresh `release/<plat>-unpacked` rebuild can leave `chrome-sandbox` without
|
||||
* the required `root:root` + setuid (mode 4755). Electron then refuses to
|
||||
* launch with "The SUID sandbox helper binary was found, but is not configured
|
||||
* correctly" and the relaunch yields "quit and never came back" — a dead app.
|
||||
* Before we quit+hand off we preflight the rebuilt sandbox helper; if it is NOT
|
||||
* launchable (and no working non-interactive fallback applies — see
|
||||
* sandboxFallbackFromEnv) we DO NOT quit. We keep the working window and return
|
||||
* the closeable manual-restart terminal state instead.
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
|
||||
// Map process.platform → electron-builder's `release/<dir>-unpacked` name.
|
||||
function unpackedDirName(platform) {
|
||||
if (platform === 'darwin') {
|
||||
return 'mac-unpacked'
|
||||
} // not used (mac swaps bundles)
|
||||
|
||||
if (platform === 'win32') {
|
||||
return 'win-unpacked'
|
||||
}
|
||||
|
||||
return 'linux-unpacked'
|
||||
}
|
||||
|
||||
/**
|
||||
* If `execPath` lives under `<updateRoot>/apps/desktop/release/<plat>-unpacked`,
|
||||
* return that unpacked dir; otherwise null. A null result means the running
|
||||
* binary is NOT the thing we just rebuilt (AppImage/.deb/.rpm/dev), so we must
|
||||
* not claim a GUI relaunch.
|
||||
*
|
||||
* Match is a path-segment-aware prefix check (not a bare string startsWith) so
|
||||
* `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`.
|
||||
*/
|
||||
function resolveUnpackedRelease(execPath, updateRoot, platform) {
|
||||
if (!execPath || !updateRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release')
|
||||
const unpacked = path.join(releaseDir, unpackedDirName(platform))
|
||||
const normalizedExec = path.resolve(String(execPath))
|
||||
// execPath must be the unpacked dir itself or a descendant of it.
|
||||
const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep
|
||||
|
||||
if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) {
|
||||
return unpacked
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision: given whether the running binary is under the rebuilt
|
||||
* unpacked release AND whether its sandbox helper is launchable, choose the
|
||||
* terminal outcome.
|
||||
*
|
||||
* 'relaunch' — quit + detached watcher re-execs the rebuilt binary in place.
|
||||
* 'guiSkew' — backend updated, GUI package NOT changed; user must reinstall
|
||||
* the GUI. Closeable terminal state; does NOT claim a GUI update.
|
||||
* 'manual' — running the rebuilt binary, but its sandbox helper is not
|
||||
* launchable and no fallback applies; do NOT quit into a dead
|
||||
* app. Closeable manual-restart terminal state.
|
||||
*/
|
||||
function decideRelaunchOutcome({ underUnpacked, sandboxOk }) {
|
||||
if (!underUnpacked) {
|
||||
return 'guiSkew'
|
||||
}
|
||||
|
||||
if (!sandboxOk) {
|
||||
return 'manual'
|
||||
}
|
||||
|
||||
return 'relaunch'
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight the rebuilt sandbox helper. Returns
|
||||
* { ok: boolean, reason: string, path: string }
|
||||
*
|
||||
* `ok` is true when chrome-sandbox is owned by uid 0 AND has the setuid bit
|
||||
* (mode & 0o4000) — i.e. Electron can launch it. If chrome-sandbox does not
|
||||
* exist at all we treat it as ok: this Electron build does not use the SUID
|
||||
* sandbox helper (e.g. it ships the namespace sandbox), so the relaunch is not
|
||||
* blocked on it.
|
||||
*
|
||||
* `statSync` is injectable so this is testable without a real setuid file.
|
||||
*/
|
||||
function sandboxPreflight(unpackedDir, statSync) {
|
||||
if (!unpackedDir) {
|
||||
return { ok: false, reason: 'no-unpacked-dir', path: null }
|
||||
}
|
||||
|
||||
const sandboxPath = path.join(unpackedDir, 'chrome-sandbox')
|
||||
let st
|
||||
|
||||
try {
|
||||
st = statSync(sandboxPath)
|
||||
} catch {
|
||||
// No chrome-sandbox helper present → this build doesn't rely on the SUID
|
||||
// sandbox; nothing to block the relaunch.
|
||||
return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath }
|
||||
}
|
||||
|
||||
const ownedByRoot = st.uid === 0
|
||||
const hasSetuid = (st.mode & 0o4000) !== 0
|
||||
|
||||
if (ownedByRoot && hasSetuid) {
|
||||
return { ok: true, reason: 'launchable', path: sandboxPath }
|
||||
}
|
||||
|
||||
if (!ownedByRoot && !hasSetuid) {
|
||||
return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath }
|
||||
}
|
||||
|
||||
if (!ownedByRoot) {
|
||||
return { ok: false, reason: 'not-root', path: sandboxPath }
|
||||
}
|
||||
|
||||
return { ok: false, reason: 'not-setuid', path: sandboxPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a non-interactive sandbox fallback the user has opted into via the
|
||||
* environment. The reviewer asked us to integrate with any existing
|
||||
* `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing
|
||||
* non-interactive sandbox fallback in the desktop app (the only chrome-sandbox
|
||||
* reference is documentation in scripts/before-pack.ts). The one signal that
|
||||
* DOES exist is the standard Electron escape hatch: ELECTRON_DISABLE_SANDBOX=1
|
||||
* (and the equivalent `--no-sandbox` already present in the launch args). If
|
||||
* the user has set that, the rebuilt binary will start even with a broken
|
||||
* chrome-sandbox, so the relaunch is safe.
|
||||
*
|
||||
* Returns true when a fallback makes the relaunch safe despite a failed
|
||||
* sandbox preflight.
|
||||
*/
|
||||
function sandboxFallbackFromEnv(env, launchArgs) {
|
||||
const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim()
|
||||
|
||||
if (disable === '1' || disable.toLowerCase() === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// POSIX single-quote a value for safe inclusion in the generated bash script.
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
// Electron / Chromium internal switches that must NOT be replayed on re-exec:
|
||||
// they are runtime artifacts of THIS launch, not user intent, and re-passing
|
||||
// them can change sandbox/zygote behavior or point at stale fds/dirs.
|
||||
const INTERNAL_ARG_PREFIXES = [
|
||||
'--type=', // renderer/gpu/zygote child markers
|
||||
'--user-data-dir=',
|
||||
'--enable-features=',
|
||||
'--disable-features=',
|
||||
'--field-trial-handle=',
|
||||
'--enable-logging',
|
||||
'--log-file=',
|
||||
// NB: --no-sandbox is deliberately NOT stripped — it reflects the user's /
|
||||
// environment's SUID-sandbox opt-out (some hardened kernels/containers require
|
||||
// it) and is the signal sandboxFallbackFromEnv() uses to allow a relaunch when
|
||||
// chrome-sandbox isn't setuid. Dropping it would make exactly that relaunch
|
||||
// fail ("quit and never came back").
|
||||
'--disable-gpu-sandbox',
|
||||
'--lang=',
|
||||
'--inspect',
|
||||
'--remote-debugging-port='
|
||||
]
|
||||
|
||||
/**
|
||||
* Filter Electron internals out of the original launch args so we replay only
|
||||
* meaningful user/launcher intent (deep-link URLs, app-specific flags).
|
||||
* `argv` is expected to be process.argv.slice(1) for a PACKAGED app (argv[0] is
|
||||
* the exec path itself; there is no entry-script arg as in a dev run).
|
||||
*/
|
||||
function collectRelaunchArgs(argv) {
|
||||
if (!Array.isArray(argv)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return argv.filter(arg => {
|
||||
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 + '=')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Env keys whose values define the relaunched instance's context (which
|
||||
// backend/profile/root it talks to). Anything HERMES_DESKTOP_* is preserved
|
||||
// plus HERMES_HOME. We snapshot the values, not the live env, so the new
|
||||
// instance comes up pointed at the same place this one was.
|
||||
// ELECTRON_DISABLE_SANDBOX is preserved for the same reason --no-sandbox is kept
|
||||
// in the replayed args: if a relaunch is only safe because the user opted out of
|
||||
// the SUID sandbox, the relaunched instance must inherit that opt-out too.
|
||||
const PRESERVED_ENV_KEYS = ['HERMES_HOME', 'ELECTRON_DISABLE_SANDBOX']
|
||||
const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_']
|
||||
|
||||
function collectRelaunchEnv(env) {
|
||||
const out = {}
|
||||
|
||||
if (!env || typeof env !== 'object') {
|
||||
return out
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) {
|
||||
out[key] = String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the detached bash watcher that waits for the parent to exit (graceful
|
||||
* window then SIGKILL), self-deletes, and re-execs the rebuilt binary WITH the
|
||||
* original launch context (cwd, env, args) restored.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {number} o.pid parent (this) process pid to wait on
|
||||
* @param {string} o.execPath binary to re-exec
|
||||
* @param {string[]} o.args filtered launch args to replay
|
||||
* @param {object} o.env env key→value to export before exec
|
||||
* @param {string} o.cwd working directory to restore
|
||||
*/
|
||||
function buildRelaunchScript({ pid, execPath, args, env, cwd }) {
|
||||
const exports = Object.entries(env || {})
|
||||
.map(([k, v]) => `export ${k}=${shellQuote(v)}`)
|
||||
.join('\n')
|
||||
|
||||
const quotedArgs = (args || []).map(shellQuote).join(' ')
|
||||
const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : ''
|
||||
|
||||
// NOTE: `exec` replaces the watcher process with the relaunched app, so the
|
||||
// re-exec inherits exactly the env/cwd we set above.
|
||||
return `#!/bin/bash
|
||||
set -u
|
||||
APP_PID=${Number(pid)}
|
||||
# Wait up to ~30s for a graceful exit, then SIGKILL: a hung/zombie parent must
|
||||
# be gone before we relaunch, or the new instance bails on the single-instance
|
||||
# lock. (#45205)
|
||||
for _ in $(seq 1 60); do
|
||||
kill -0 "$APP_PID" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
if kill -0 "$APP_PID" 2>/dev/null; then
|
||||
kill -9 "$APP_PID" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
fi
|
||||
# Self-delete so temp watchers don't accumulate across updates.
|
||||
rm -f -- "$0" 2>/dev/null || true
|
||||
${cwdLine}
|
||||
${exports}
|
||||
exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''}
|
||||
`
|
||||
}
|
||||
|
||||
export {
|
||||
buildRelaunchScript,
|
||||
collectRelaunchArgs,
|
||||
collectRelaunchEnv,
|
||||
decideRelaunchOutcome,
|
||||
INTERNAL_ARG_PREFIXES,
|
||||
PRESERVED_ENV_KEYS,
|
||||
PRESERVED_ENV_PREFIXES,
|
||||
resolveUnpackedRelease,
|
||||
sandboxFallbackFromEnv,
|
||||
sandboxPreflight,
|
||||
shellQuote,
|
||||
unpackedDirName
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -170,7 +173,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 +186,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 +219,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,
|
||||
|
|
@ -233,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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -94,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
|
||||
|
|
|
|||
|
|
@ -38,6 +38,13 @@
|
|||
"dist:win:nsis": "npm run build && npm run builder -- --win nsis",
|
||||
"dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm",
|
||||
"perf": "node scripts/perf/run.mjs",
|
||||
"update:shim": "bash ../../scripts/desktop-update/repro.sh shim",
|
||||
"update:shim:fail": "bash ../../scripts/desktop-update/repro.sh shim-fail",
|
||||
"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",
|
||||
"update:repro:launch": "bash ../../scripts/desktop-update/repro.sh launch",
|
||||
"perf:serve": "node scripts/perf/serve.mjs",
|
||||
"test:desktop": "node scripts/test-desktop.mjs",
|
||||
"test:desktop:all": "node scripts/test-desktop.mjs all",
|
||||
|
|
|
|||
|
|
@ -1,429 +1,11 @@
|
|||
# desktop-update.ps1 -- repo-owned Windows Desktop update hand-off.
|
||||
# COMPAT FORWARDER — do not add logic here.
|
||||
#
|
||||
# WHY THIS EXISTS (the frozen-binary problem): the Desktop's Update button
|
||||
# used to hand off exclusively to the staged Tauri binary
|
||||
# (%HERMES_HOME%\hermes-setup.exe). That binary has no self-update path --
|
||||
# copy_self_to_hermes_home deliberately no-ops during --update -- so every
|
||||
# updater-side fix (cache refresh #67369, marker self-adopt #74782, straggler
|
||||
# handling) only reaches users when a new installer is built, signed, and
|
||||
# published. In practice binaries go months stale and users hit long-fixed
|
||||
# bugs on every update (the 2026-08-09 incident chain).
|
||||
#
|
||||
# This script lives in the repo checkout, so EVERY `hermes update` refreshes
|
||||
# the very code that drives the next update. The Desktop spawns it through a
|
||||
# `cmd start` wrapper (see wrapHandoffForDetachedConsole in
|
||||
# apps/desktop/electron/updater-process.ts -- a bare detached+hidden
|
||||
# powershell dies before -File runs) and exits; only PowerShell itself -- an
|
||||
# OS component -- is "frozen".
|
||||
#
|
||||
# CONTRACT (keep in sync with apps/desktop/electron/main.ts):
|
||||
# cmd /d /s /c start "" /min powershell -NoProfile -ExecutionPolicy Bypass
|
||||
# -File scripts\desktop-update.ps1
|
||||
# -InstallRoot <path> repo checkout (HERMES_HOME\hermes-agent)
|
||||
# -Branch <ref> branch to update against
|
||||
# -DesktopPid <pid> the Electron main process to wait out
|
||||
# [-RelaunchExe <path>] Hermes.exe to start when done (omit = no relaunch)
|
||||
# [-NoUi] headless (tests); default shows a progress window
|
||||
# [-NoMarkerCleanup] leave .hermes-update-in-progress in place (tests)
|
||||
#
|
||||
# SAFETY POSTURE: both preflight gates FAIL CLOSED. A Desktop that never
|
||||
# exits, or a venv shim that never unlocks, aborts the hand-off without
|
||||
# mutating the install -- a skipped update is recoverable, a half-updated
|
||||
# venv is not. Every exit path (success, abort, crash) writes
|
||||
# .hermes-update-result.json for the relaunched Desktop to surface, and
|
||||
# relaunches the Desktop so the user is never left stranded.
|
||||
#
|
||||
# Marker: we claim HERMES_HOME\.hermes-update-in-progress with OUR pid as
|
||||
# step 0 (the wrapper cmd.exe pid the Desktop saw is useless -- it exits
|
||||
# immediately). hermes_cli/update_lock.py's ancestry rule lets our
|
||||
# `hermes update` child adopt the claim; electron/update-marker.ts parks a
|
||||
# relaunched Desktop on it. Cleanup only removes the marker while WE still
|
||||
# own it (a handoff partner that rewrote it keeps its claim).
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$InstallRoot,
|
||||
[string]$Branch = "main",
|
||||
[int]$DesktopPid = 0,
|
||||
[string]$RelaunchExe = "",
|
||||
[switch]$NoUi,
|
||||
[switch]$NoMarkerCleanup
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
# Foreground helpers: the script is spawned via `cmd start /min`, so its
|
||||
# WinForms window comes up backgrounded unless we explicitly claim focus --
|
||||
# and after the update we must hand focus TO the relaunched Desktop (a
|
||||
# WMI-spawned process starts unfocused). AllowSetForegroundWindow lets us
|
||||
# pass our foreground right on to the new Hermes.exe pid.
|
||||
try {
|
||||
Add-Type -Namespace HermesHandoff -Name Win32 -MemberDefinition @'
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int dwProcessId);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow);
|
||||
'@ -ErrorAction Stop
|
||||
$script:Win32 = $true
|
||||
} catch { $script:Win32 = $false }
|
||||
# Render UTF-8 glyphs (checkmarks, arrows) correctly in our own console echo
|
||||
# too; the legacy conhost default OEM codepage shows them as mojibake.
|
||||
try {
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
} catch {}
|
||||
$HermesHome = Split-Path -Parent $InstallRoot
|
||||
$MarkerPath = Join-Path $HermesHome ".hermes-update-in-progress"
|
||||
$LogDir = Join-Path $HermesHome "logs"
|
||||
$LogPath = Join-Path $LogDir "desktop-update-handoff.log"
|
||||
$ResultPath = Join-Path $HermesHome ".hermes-update-result.json"
|
||||
$script:Ui = $null
|
||||
|
||||
function Write-HandoffLog([string]$Message) {
|
||||
$line = "{0:yyyy-MM-ddTHH:mm:ssK} {1}" -f (Get-Date), $Message
|
||||
try { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } catch {}
|
||||
Write-Host $line
|
||||
if ($script:Ui) {
|
||||
try {
|
||||
$script:Ui.Box.AppendText($Message + "`r`n")
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function Show-ProgressWindow {
|
||||
if ($NoUi) { return }
|
||||
try {
|
||||
Add-Type -AssemblyName System.Windows.Forms | Out-Null
|
||||
Add-Type -AssemblyName System.Drawing | Out-Null
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "Hermes Update"
|
||||
$form.Size = New-Object System.Drawing.Size(720, 420)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
$form.ControlBox = $false
|
||||
$form.TopMost = $true
|
||||
$label = New-Object System.Windows.Forms.Label
|
||||
$label.Text = "Updating Hermes -- do not close this window. Hermes restarts automatically when the update finishes."
|
||||
$label.Dock = "Top"
|
||||
$label.Height = 34
|
||||
$label.Padding = New-Object System.Windows.Forms.Padding(8, 8, 8, 0)
|
||||
$bar = New-Object System.Windows.Forms.ProgressBar
|
||||
$bar.Style = "Marquee"
|
||||
$bar.MarqueeAnimationSpeed = 30
|
||||
$bar.Dock = "Top"
|
||||
$bar.Height = 18
|
||||
$box = New-Object System.Windows.Forms.TextBox
|
||||
$box.Multiline = $true
|
||||
$box.ReadOnly = $true
|
||||
$box.ScrollBars = "Vertical"
|
||||
$box.Dock = "Fill"
|
||||
$box.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||
$form.Controls.Add($box)
|
||||
$form.Controls.Add($bar)
|
||||
$form.Controls.Add($label)
|
||||
$form.Show()
|
||||
# `cmd start /min` spawned us backgrounded; TopMost keeps the window
|
||||
# above others but does not take activation. Claim it explicitly so
|
||||
# the progress window is what the user sees during the update.
|
||||
try {
|
||||
$form.Activate()
|
||||
if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($form.Handle) | Out-Null }
|
||||
} catch {}
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
$script:Ui = [pscustomobject]@{ Form = $form; Box = $box }
|
||||
} catch {
|
||||
# Headless session / WinForms unavailable: degrade to log-only.
|
||||
$script:Ui = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Close-ProgressWindow {
|
||||
if ($script:Ui) {
|
||||
try { $script:Ui.Form.Close() } catch {}
|
||||
$script:Ui = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Result([bool]$Ok, [int]$Code, [string]$Message) {
|
||||
# Consumed (read + deleted) by the relaunched Desktop on boot so the
|
||||
# user actually SEES how a detached update ended.
|
||||
try {
|
||||
$obj = @{
|
||||
ok = $Ok
|
||||
exit_code = $Code
|
||||
message = $Message
|
||||
branch = $Branch
|
||||
finished_at = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
} | ConvertTo-Json -Compress
|
||||
[System.IO.File]::WriteAllText($ResultPath, $obj)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Remove-MarkerIfOwned {
|
||||
if ($NoMarkerCleanup) { return }
|
||||
try {
|
||||
if (Test-Path -LiteralPath $MarkerPath) {
|
||||
$firstLine = (Get-Content -LiteralPath $MarkerPath -TotalCount 1 -ErrorAction SilentlyContinue)
|
||||
if ("$firstLine".Trim() -eq "$PID") {
|
||||
Remove-Item -LiteralPath $MarkerPath -Force -ErrorAction SilentlyContinue
|
||||
Write-HandoffLog "removed update marker (owned)"
|
||||
} else {
|
||||
Write-HandoffLog "leaving update marker: owned by pid '$firstLine', not us ($PID)"
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Start-DesktopRelaunch {
|
||||
if ($RelaunchExe -and (Test-Path -LiteralPath $RelaunchExe)) {
|
||||
Write-HandoffLog "relaunching desktop: $RelaunchExe"
|
||||
# DO NOT spawn Hermes.exe as our child: Electron/Chromium calls
|
||||
# AttachConsole(ATTACH_PARENT_PROCESS) at boot, so a Desktop launched
|
||||
# directly from this console PowerShell latches onto OUR console --
|
||||
# the console window then outlives the script (it can't close while
|
||||
# an attached process lives), and closing it kills the freshly
|
||||
# relaunched GUI with it. Create the process via WMI instead: the
|
||||
# parent becomes WmiPrvSE.exe and there is no console to inherit or
|
||||
# attach -- same detachment explorer.exe gives a normal launch.
|
||||
$spawned = $false
|
||||
try {
|
||||
$workDir = Split-Path -Parent $RelaunchExe
|
||||
$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
||||
CommandLine = ('"{0}"' -f $RelaunchExe)
|
||||
CurrentDirectory = $workDir
|
||||
} -ErrorAction Stop
|
||||
if ($r -and $r.ReturnValue -eq 0) {
|
||||
Write-HandoffLog "desktop relaunched detached (pid $($r.ProcessId))"
|
||||
$spawned = $true
|
||||
# Hand our foreground rights to the new Desktop and focus its
|
||||
# main window once it exists. A WMI-spawned process starts
|
||||
# unfocused, and Windows only lets the CURRENT foreground
|
||||
# owner (us, while the progress window is up / just closed)
|
||||
# delegate that right. Poll briefly for the window: Electron
|
||||
# takes a couple seconds to create it.
|
||||
try {
|
||||
if ($script:Win32) {
|
||||
[HermesHandoff.Win32]::AllowSetForegroundWindow([int]$r.ProcessId) | Out-Null
|
||||
$deadline = (Get-Date).AddSeconds(20)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$hwnd = [System.IntPtr]::Zero
|
||||
try {
|
||||
$p = Get-Process -Id $r.ProcessId -ErrorAction Stop
|
||||
$hwnd = $p.MainWindowHandle
|
||||
} catch { break } # process died; nothing to focus
|
||||
if ($hwnd -ne [System.IntPtr]::Zero) {
|
||||
[HermesHandoff.Win32]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE
|
||||
[HermesHandoff.Win32]::SetForegroundWindow($hwnd) | Out-Null
|
||||
Write-HandoffLog "focused relaunched desktop window"
|
||||
break
|
||||
}
|
||||
Start-Sleep -Milliseconds 400
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-HandoffLog "WARNING: could not focus relaunched desktop: $($_.Exception.Message)"
|
||||
}
|
||||
} else {
|
||||
Write-HandoffLog "WARNING: WMI relaunch returned $($r.ReturnValue); falling back"
|
||||
}
|
||||
} catch {
|
||||
Write-HandoffLog "WARNING: WMI relaunch failed: $($_.Exception.Message); falling back"
|
||||
}
|
||||
if (-not $spawned) {
|
||||
try {
|
||||
# Fallback keeps the old behavior (console tie-in and all) --
|
||||
# a tethered Desktop beats no Desktop.
|
||||
Start-Process -FilePath $RelaunchExe -WorkingDirectory (Split-Path -Parent $RelaunchExe) | Out-Null
|
||||
} catch {
|
||||
Write-HandoffLog "WARNING: desktop relaunch failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-StreamedHermes([string]$Exe, [string[]]$HermesArgs, [string]$Tag) {
|
||||
# Start-Process + output file + poll keeps the WinForms window pumping
|
||||
# during long silent stretches (pip installs); a blocking pipeline would
|
||||
# freeze the marquee. Returns @{ Code; Output }.
|
||||
$outFile = Join-Path $env:TEMP ("hermes-handoff-{0}-{1}.out" -f $Tag, $PID)
|
||||
$errFile = Join-Path $env:TEMP ("hermes-handoff-{0}-{1}.err" -f $Tag, $PID)
|
||||
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
|
||||
# System.Diagnostics.Process directly: Start-Process's .ExitCode is
|
||||
# unreliably $null under PS 5.1 even with the Handle-touch workaround.
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
# .Arguments string (PS 5.1 / .NET Framework has no ArgumentList).
|
||||
# Args here are fixed flags + a branch ref; quote each defensively.
|
||||
$psi.Arguments = ($HermesArgs | ForEach-Object { '"{0}"' -f ($_ -replace '"', '\"') }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
# hermes update prints UTF-8 (checkmarks, arrows, box glyphs). PS 5.1
|
||||
# defaults these readers to the OEM codepage, which mangles every
|
||||
# multi-byte glyph into mojibake in the console AND the progress box.
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
|
||||
# And ask the child to actually EMIT UTF-8: Python decides its stdio
|
||||
# encoding from the console codepage when attached to one.
|
||||
$psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"
|
||||
$psi.EnvironmentVariables["PYTHONUTF8"] = "1"
|
||||
$psi.CreateNoWindow = $true
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$outWriter = [System.IO.File]::CreateText($outFile)
|
||||
$errWriter = [System.IO.File]::CreateText($errFile)
|
||||
# Pump synchronously in small reads so the UI stays alive; stderr is
|
||||
# drained at the end (hermes update is stdout-dominant).
|
||||
while (-not $proc.HasExited) {
|
||||
while (-not $proc.StandardOutput.EndOfStream) {
|
||||
$ln = $proc.StandardOutput.ReadLine()
|
||||
if ($null -ne $ln) {
|
||||
$outWriter.WriteLine($ln)
|
||||
if ($ln.Trim()) { Write-HandoffLog ("{0}| {1}" -f $Tag, $ln) }
|
||||
}
|
||||
if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
|
||||
}
|
||||
Start-Sleep -Milliseconds 150
|
||||
if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
|
||||
}
|
||||
while (-not $proc.StandardOutput.EndOfStream) {
|
||||
$ln = $proc.StandardOutput.ReadLine()
|
||||
if ($null -ne $ln) {
|
||||
$outWriter.WriteLine($ln)
|
||||
if ($ln.Trim()) { Write-HandoffLog ("{0}| {1}" -f $Tag, $ln) }
|
||||
}
|
||||
}
|
||||
$errText = $proc.StandardError.ReadToEnd()
|
||||
if ($errText) {
|
||||
$errWriter.Write($errText)
|
||||
foreach ($ln in ($errText -split "`r?`n")) {
|
||||
if ($ln.Trim()) { Write-HandoffLog ("{0}!| {1}" -f $Tag, $ln) }
|
||||
}
|
||||
}
|
||||
$outWriter.Close(); $errWriter.Close()
|
||||
$proc.WaitForExit()
|
||||
$code = $proc.ExitCode
|
||||
$all = ""
|
||||
try { $all = [System.IO.File]::ReadAllText($outFile) } catch {}
|
||||
if ($errText) { $all += "`n" + $errText }
|
||||
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
|
||||
return @{ Code = $code; Output = $all }
|
||||
}
|
||||
|
||||
$finalCode = 1
|
||||
$finalMsg = "update did not complete"
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue
|
||||
Show-ProgressWindow
|
||||
Write-HandoffLog "hand-off start: root=$InstallRoot branch=$Branch desktopPid=$DesktopPid pid=$PID"
|
||||
|
||||
# -- 0. Claim the update marker with OUR pid ---------------------------
|
||||
try {
|
||||
$epoch = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
# WriteAllText for byte-exact LF framing: Set-Content emits CRLF and
|
||||
# the marker contract (Rust/TS/Python readers) is "<pid>\n<ts>\n".
|
||||
[System.IO.File]::WriteAllText($MarkerPath, "$PID`n$epoch`n")
|
||||
Write-HandoffLog "claimed update marker (pid $PID)"
|
||||
} catch {
|
||||
Write-HandoffLog "WARNING: could not write update marker: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# -- 1. Wait for the Desktop to exit (FAIL CLOSED) ----------------------
|
||||
if ($DesktopPid -gt 0) {
|
||||
$deadline = (Get-Date).AddSeconds(30)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$proc = Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) { break }
|
||||
Start-Sleep -Milliseconds 300
|
||||
if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
|
||||
}
|
||||
if (Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue) {
|
||||
# A live Desktop means a live backend re-locking the venv at any
|
||||
# moment. Updating under it is how installs brick. Abort.
|
||||
$finalCode = 4
|
||||
$finalMsg = "Update aborted: the Hermes window (pid $DesktopPid) did not exit within 30s. Nothing was changed. Close Hermes fully and try again."
|
||||
Write-HandoffLog $finalMsg
|
||||
exit $finalCode
|
||||
}
|
||||
Write-HandoffLog "desktop exited"
|
||||
}
|
||||
|
||||
# -- 2. Wait for the venv shim to unlock (FAIL CLOSED) ------------------
|
||||
$shim = Join-Path $InstallRoot "venv\Scripts\hermes.exe"
|
||||
if (Test-Path -LiteralPath $shim) {
|
||||
$unlocked = $false
|
||||
$deadline = (Get-Date).AddSeconds(20)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$fs = [System.IO.File]::Open($shim, 'Open', 'ReadWrite', 'None')
|
||||
$fs.Close()
|
||||
$unlocked = $true
|
||||
break
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 400
|
||||
if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
|
||||
}
|
||||
}
|
||||
if (-not $unlocked) {
|
||||
# Something still maps the venv. --force-ing past it guarantees a
|
||||
# half-updated venv (the exact 2026-08-09 Access-denied brick).
|
||||
$finalCode = 5
|
||||
$finalMsg = "Update aborted: another process is still holding the Hermes install open (venv\Scripts\hermes.exe locked after 20s). Nothing was changed. Close other Hermes windows/terminals and try again."
|
||||
Write-HandoffLog $finalMsg
|
||||
exit $finalCode
|
||||
}
|
||||
Write-HandoffLog "venv shim unlocked"
|
||||
}
|
||||
|
||||
# -- 3. Run the update from the CURRENT checkout ------------------------
|
||||
# --force skips only the hermes.exe shim guard, which step 2 just PROVED
|
||||
# is unlocked; the venv-python holder guard (orphan reap included) stays
|
||||
# active. Our marker claim is adopted by the child via update_lock.py's
|
||||
# process-ancestry rule.
|
||||
$hermesExe = Join-Path $InstallRoot "venv\Scripts\hermes.exe"
|
||||
if (-not (Test-Path -LiteralPath $hermesExe)) {
|
||||
$finalCode = 3
|
||||
$finalMsg = "Update aborted: $hermesExe is missing. The install needs repair (run the Hermes installer or `hermes doctor`)."
|
||||
Write-HandoffLog $finalMsg
|
||||
exit $finalCode
|
||||
}
|
||||
$updateArgs = @("update", "--yes", "--gateway", "--force", "--branch", $Branch)
|
||||
Write-HandoffLog ("running: hermes " + ($updateArgs -join " "))
|
||||
$res = Invoke-StreamedHermes $hermesExe $updateArgs "update"
|
||||
Write-HandoffLog "hermes update exit code: $($res.Code)"
|
||||
|
||||
if ($res.Code -ne 0 -and $res.Code -ne 2) {
|
||||
# One retry for the update-boundary class (fresh code on disk, stale
|
||||
# code in memory). Exit 2 ("close all Hermes windows") is not retryable.
|
||||
Write-HandoffLog "first attempt failed; retrying once (freshly pulled fix loads on the second run)"
|
||||
$res = Invoke-StreamedHermes $hermesExe $updateArgs "update"
|
||||
Write-HandoffLog "retry exit code: $($res.Code)"
|
||||
}
|
||||
|
||||
# -- 4. Truthful completion: don't trust exit 0 -------------------------
|
||||
# `hermes update` treats a Desktop GUI build failure as NON-fatal (prints
|
||||
# a one-line warning, exits 0). For a Desktop-DRIVEN update that warning
|
||||
# is fatal: we would relaunch the old exe and call it success. Detect it,
|
||||
# retry the build once, and propagate honestly.
|
||||
$desktopBuildFailed = $false
|
||||
if ($res.Code -eq 0 -and $res.Output -match "Desktop build failed") {
|
||||
Write-HandoffLog "hermes update reported a desktop build failure (non-fatal there, fatal here); retrying build"
|
||||
$rebuild = Invoke-StreamedHermes $hermesExe @("desktop", "--force-build", "--build-only") "rebuild"
|
||||
Write-HandoffLog "desktop rebuild exit code: $($rebuild.Code)"
|
||||
if ($rebuild.Code -ne 0) { $desktopBuildFailed = $true }
|
||||
}
|
||||
|
||||
if ($res.Code -eq 0 -and -not $desktopBuildFailed) {
|
||||
$finalCode = 0
|
||||
$finalMsg = "Update complete."
|
||||
} elseif ($desktopBuildFailed) {
|
||||
$finalCode = 6
|
||||
$finalMsg = "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."
|
||||
} else {
|
||||
$finalCode = $res.Code
|
||||
$finalMsg = "hermes update failed (exit $($res.Code)). See logs\desktop-update-handoff.log."
|
||||
}
|
||||
exit $finalCode
|
||||
} finally {
|
||||
Write-Result ($finalCode -eq 0) $finalCode $finalMsg
|
||||
Remove-MarkerIfOwned
|
||||
Close-ProgressWindow
|
||||
Start-DesktopRelaunch
|
||||
}
|
||||
# The hand-off moved to scripts/desktop-update/windows.ps1. This forwarder
|
||||
# exists for exactly one consumer: an already-installed Desktop whose asar
|
||||
# is one update behind and still spawns scripts/desktop-update.ps1 (see
|
||||
# resolveUpdateScriptHandoff in apps/desktop/electron/updater-process.ts).
|
||||
# Without it, that Desktop would silently fall back to the frozen staged
|
||||
# Tauri binary for one update cycle — the exact rot this script family
|
||||
# exists to escape.
|
||||
& (Join-Path $PSScriptRoot "desktop-update\windows.ps1") @args
|
||||
exit $LASTEXITCODE
|
||||
|
|
|
|||
|
|
@ -0,0 +1,428 @@
|
|||
#!/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 <path> repo checkout (HERMES_HOME/hermes-agent)
|
||||
# --branch <ref> branch to update against
|
||||
# --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)
|
||||
# [--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=""
|
||||
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
|
||||
[ "$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"
|
||||
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"
|
||||
}
|
||||
|
||||
notify_fallback() { # status message — renderer-free recovery surface.
|
||||
# Fires only when there is no shim window. BEST-EFFORT immediate channel:
|
||||
# each rung requires EXECUTION acceptance, not existence — notify-send's
|
||||
# exit code is its acceptance (fire-and-forget), zenity/kdialog must
|
||||
# survive their first second (a dialog that dies instantly had no display
|
||||
# and must not eat the message). The GUARANTEED channel is the result
|
||||
# file: a manual/error outcome is durably marked and the next Desktop
|
||||
# boot surfaces it in a dialog (handoff-result.ts + main.ts).
|
||||
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
|
||||
local p
|
||||
if command -v zenity >/dev/null 2>&1; then
|
||||
zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &
|
||||
p=$!; sleep 1
|
||||
kill -0 "$p" 2>/dev/null && return 0
|
||||
wait "$p" 2>/dev/null
|
||||
fi
|
||||
if command -v kdialog >/dev/null 2>&1; then
|
||||
kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &
|
||||
p=$!; sleep 1
|
||||
kill -0 "$p" 2>/dev/null && return 0
|
||||
wait "$p" 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
# No immediate surface landed. The durable channel takes over: the result
|
||||
# is marked manual/failed and the next boot shows it in a real dialog.
|
||||
log "NOTICE: no notification surface accepted; outcome reaches the user via the result dialog 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
|
||||
[ -z "$UI_SERVER_PID" ] && notify_fallback "$1" "$2"
|
||||
}
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
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
|
||||
fi
|
||||
}
|
||||
|
||||
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() { # attempted BEFORE the terminal event (launch acceptance is
|
||||
# part of the outcome — gille's review). Returns nonzero when a launch
|
||||
# was due but did not verifiably happen; caller downgrades to manual.
|
||||
[ -n "$RELAUNCH_TARGET" ] || return 0
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
# 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.
|
||||
/usr/bin/open "$RELAUNCH_TARGET" || { log "WARNING: open rejected the app"; return 1; }
|
||||
elif [ "$GATE" = "relaunch" ]; then
|
||||
# setsid only proves the wrapper shell started, so verify acceptance:
|
||||
# spawn, then confirm the child is still alive shortly after — an
|
||||
# immediate exec failure (ENOENT, ELF mismatch, dead sandbox) dies
|
||||
# within the window and downgrades to manual instead of lying.
|
||||
(cd "${RELAUNCH_CWD:-/}" 2>/dev/null || cd /
|
||||
setsid "$RELAUNCH_TARGET" ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"} >/dev/null 2>&1 &
|
||||
echo $! > "$STATUS.launchpid") || { log "WARNING: relaunch spawn failed"; return 1; }
|
||||
local lp
|
||||
lp="$(cat "$STATUS.launchpid" 2>/dev/null)"; rm -f "$STATUS.launchpid" 2>/dev/null
|
||||
[ -n "$lp" ] || { log "WARNING: relaunch pid unknown"; return 1; }
|
||||
sleep 1.5
|
||||
kill -0 "$lp" 2>/dev/null || { log "WARNING: relaunched app exited immediately"; return 1; }
|
||||
fi
|
||||
}
|
||||
|
||||
MANUAL=0 # 1 = update landed but the user must act (result protocol field)
|
||||
|
||||
write_result() {
|
||||
printf '{"ok":%s,"exit_code":%s,"manual":%s,"message":"%s","branch":"%s","finished_at":%s}' \
|
||||
"$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" \
|
||||
"$([ "$MANUAL" -eq 1 ] && echo true || echo false)" \
|
||||
"$(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 (gille's reviews, both rounds):
|
||||
# 1. deliver the outcome (swap/gate) so the truth exists;
|
||||
# 2. durable result + marker removal (the relaunched app consumes the
|
||||
# result on boot and must not park on our marker — this must be on
|
||||
# disk BEFORE any launch attempt);
|
||||
# 3. attempt the launch and require ACCEPTANCE;
|
||||
# 4. only then the terminal shim event — done means "the app is coming
|
||||
# back", manual means "it is not, here's what to do", error is error.
|
||||
# A rejected launch rewrites the result (nothing consumed it — the app
|
||||
# never started) so the next boot tells the truth too.
|
||||
deliver_outcome
|
||||
[ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && { FINAL_MSG="$DONE_NOTE"; MANUAL=1; }
|
||||
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" -ne 0 ]; then
|
||||
publish "error" "$FINAL_MSG"; stop_ui leave-window
|
||||
launch_app || true # error path still tries to bring the app back
|
||||
rm -f "$STATUS" "$STATUS.tmp" "$LOG_DIR/desktop-update-ui-port" 2>/dev/null || true
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$DONE_NOTE" ]; then
|
||||
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.
|
||||
if ! launch_app; then
|
||||
# Even the kept bundle didn't come back: the durable message must
|
||||
# carry BOTH facts (update ok, previous app not reopened).
|
||||
FINAL_MSG="$DONE_NOTE Hermes also could not reopen itself - open it manually."
|
||||
write_result
|
||||
fi
|
||||
fi
|
||||
publish "manual" "$FINAL_MSG"; stop_ui leave-window
|
||||
elif launch_app; then
|
||||
publish "done" ""; stop_ui
|
||||
else
|
||||
# Launch was due and did not land. Downgrade: truthful result for the
|
||||
# next boot, manual state held on screen now.
|
||||
FINAL_MSG="Update complete. Reopen Hermes to finish (it could not restart itself)."
|
||||
MANUAL=1
|
||||
write_result
|
||||
publish "manual" "$FINAL_MSG"; stop_ui leave-window
|
||||
fi
|
||||
rm -f "$STATUS" "$STATUS.tmp" "$LOG_DIR/desktop-update-ui-port" 2>/dev/null || true
|
||||
}
|
||||
trap finish EXIT
|
||||
|
||||
# ── 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)"
|
||||
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; }
|
||||
|
||||
# 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). 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=$?
|
||||
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"
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
#!/bin/bash
|
||||
# repro.sh -- reproduce desktop-update paths against a sandboxed HERMES_HOME.
|
||||
#
|
||||
# Nothing here touches your real ~/.hermes or checkout. Each mode builds (or
|
||||
# reuses) a disposable install under /tmp and drives the REAL code path --
|
||||
# the actual installer, the actual orchestrator, the actual `hermes update`.
|
||||
#
|
||||
# repro.sh shim shim UI only: success event after 6s
|
||||
# repro.sh shim-fail shim UI only: error event after 6s
|
||||
# repro.sh fresh fresh install into a sandbox HERMES_HOME
|
||||
# (scripts/install.sh, the literal user path)
|
||||
# repro.sh behind [N] sandbox install rewound N commits (default 25),
|
||||
# then the posix orchestrator drives it forward --
|
||||
# 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
|
||||
# a from-scratch install is minutes.
|
||||
#
|
||||
# npm entry points (apps/desktop/package.json):
|
||||
# npm run update:shim / update:shim:fail / update:repro:fresh /
|
||||
# update:repro:behind [-- N] / update:repro:error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODE="${1:-help}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
SANDBOX="${HERMES_UPDATE_REPRO_HOME:-/tmp/hermes-update-repro}"
|
||||
SANDBOX_ROOT="$SANDBOX/hermes-agent"
|
||||
|
||||
say() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
|
||||
|
||||
ensure_sandbox_install() {
|
||||
if [ -x "$SANDBOX_ROOT/venv/bin/hermes" ]; then
|
||||
say "reusing sandbox install at $SANDBOX_ROOT"
|
||||
return
|
||||
fi
|
||||
say "fresh sandbox install into $SANDBOX (this takes a while)"
|
||||
rm -rf "$SANDBOX"
|
||||
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.
|
||||
git clone --quiet "$REPO_ROOT" "$SANDBOX_ROOT"
|
||||
HERMES_HOME="$SANDBOX" bash "$SANDBOX_ROOT/scripts/install.sh" --non-interactive --skip-setup --hermes-home "$SANDBOX"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
shim)
|
||||
HERMES_SELFTEST_HOLD_SECONDS="${HERMES_SELFTEST_HOLD_SECONDS:-6}" \
|
||||
bash "$SCRIPT_DIR/posix.sh" --self-test-ui
|
||||
;;
|
||||
shim-fail)
|
||||
HERMES_SELFTEST_FAIL=1 HERMES_SELFTEST_HOLD_SECONDS="${HERMES_SELFTEST_HOLD_SECONDS:-6}" \
|
||||
bash "$SCRIPT_DIR/posix.sh" --self-test-ui
|
||||
;;
|
||||
fresh)
|
||||
rm -rf "$SANDBOX"
|
||||
ensure_sandbox_install
|
||||
say "fresh install OK: $("$SANDBOX_ROOT/venv/bin/hermes" --version 2>/dev/null || echo '?')"
|
||||
;;
|
||||
behind)
|
||||
N="${2:-25}"
|
||||
ensure_sandbox_install
|
||||
say "rewinding sandbox checkout $N commits"
|
||||
git -C "$SANDBOX_ROOT" fetch --quiet origin main || true
|
||||
git -C "$SANDBOX_ROOT" checkout --quiet main
|
||||
git -C "$SANDBOX_ROOT" reset --hard --quiet "HEAD~$N"
|
||||
say "sandbox now at: $(git -C "$SANDBOX_ROOT" log --oneline -1)"
|
||||
say "driving the orchestrator (watch the shim; log: $SANDBOX/logs/desktop-update-handoff.log)"
|
||||
HERMES_HOME="$SANDBOX" bash "$SCRIPT_DIR/posix.sh" \
|
||||
--install-root "$SANDBOX_ROOT" --branch main --desktop-pid 0 || true
|
||||
say "result file:"
|
||||
cat "$SANDBOX/.hermes-update-result.json" 2>/dev/null || echo "(none written)"
|
||||
echo
|
||||
say "sandbox after update: $(git -C "$SANDBOX_ROOT" log --oneline -1)"
|
||||
;;
|
||||
error)
|
||||
ensure_sandbox_install
|
||||
say "breaking the sandbox venv, then driving the orchestrator"
|
||||
mv "$SANDBOX_ROOT/venv" "$SANDBOX_ROOT/venv.hidden"
|
||||
HERMES_HOME="$SANDBOX" bash "$SCRIPT_DIR/posix.sh" \
|
||||
--install-root "$SANDBOX_ROOT" --branch main --desktop-pid 0 || true
|
||||
mv "$SANDBOX_ROOT/venv.hidden" "$SANDBOX_ROOT/venv"
|
||||
say "result file (expect ok:false, exit 3):"
|
||||
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; }
|
||||
;;
|
||||
launch)
|
||||
# Terminal-lifecycle matrix (gille round 2): launch acceptance is part
|
||||
# of the outcome. Each case runs the REAL orchestrator (--no-ui) against
|
||||
# a fake install whose `hermes` stub exits 0 instantly, so the flow
|
||||
# reaches finish() with FINAL_CODE=0 and exercises the launch leg.
|
||||
L="/tmp/hermes-launch-test.$$"
|
||||
fails=0
|
||||
expect_msg() { # name python-expr
|
||||
if python3 -c "import json,sys; d=json.load(open('$L/.hermes-update-result.json')); sys.exit(0 if ($2) else 1)"; then
|
||||
printf 'ok %s\n' "$1"
|
||||
else
|
||||
printf 'FAIL %s -> %s\n' "$1" "$(cat "$L/.hermes-update-result.json" 2>/dev/null)"; fails=$((fails+1))
|
||||
fi
|
||||
}
|
||||
stub_install() { # creates a fake install whose hermes update succeeds
|
||||
rm -rf "$L"; mkdir -p "$L/hermes-agent/venv/bin"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$L/hermes-agent/venv/bin/hermes"
|
||||
chmod +x "$L/hermes-agent/venv/bin/hermes"
|
||||
}
|
||||
|
||||
# 1. linux relaunch target dies instantly -> manual downgrade in result
|
||||
stub_install
|
||||
UNPACKED="$L/hermes-agent/apps/desktop/release/linux-unpacked"
|
||||
mkdir -p "$UNPACKED"
|
||||
printf '#!/bin/sh\nexit 1\n' > "$UNPACKED/hermes"; chmod +x "$UNPACKED/hermes"
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
|
||||
--relaunch-target "$UNPACKED/hermes" >/dev/null 2>&1 || true
|
||||
expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']"
|
||||
else
|
||||
# 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 downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']"
|
||||
fi
|
||||
|
||||
# 2. gated skew: success result carries the skew message (the manual
|
||||
# event's payload), never a bare "Update complete."
|
||||
stub_install
|
||||
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
|
||||
--relaunch-target /opt/Hermes/hermes >/dev/null 2>&1 || true
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
expect_msg "skew outcome surfaces in result message" "d['ok']==True and d['manual']==True and 'was not changed' in d['message']"
|
||||
fi
|
||||
|
||||
rm -rf "$L"
|
||||
[ "$fails" -eq 0 ] && say "launch matrix: all pass" || { say "launch matrix: $fails FAILED"; exit 1; }
|
||||
;;
|
||||
*)
|
||||
sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"""Loopback shim server for the desktop update hand-off.
|
||||
|
||||
Two GET routes: / serves ui.html, /progress serves the status file the
|
||||
orchestrator script writes ({"status": "running"|"done"|"error", ...}).
|
||||
Exists because a file:// page cannot receive events from a detached
|
||||
process. Prints the chosen ephemeral port on stdout, serves until killed.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import socketserver
|
||||
import sys
|
||||
|
||||
html_path, status_path = sys.argv[1], sys.argv[2]
|
||||
with open(html_path, "rb") as f:
|
||||
HTML = f.read()
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args): # noqa: A002 - base class signature
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/progress"):
|
||||
try:
|
||||
with open(status_path, "rb") as f:
|
||||
body = f.read()
|
||||
json.loads(body)
|
||||
except Exception:
|
||||
body = b'{"status":"running","message":""}'
|
||||
ctype = "application/json; charset=utf-8"
|
||||
elif self.path == "/":
|
||||
body, ctype = HTML, "text/html; charset=utf-8"
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
with socketserver.TCPServer(("127.0.0.1", 0), Handler) as srv:
|
||||
print(srv.server_address[1], flush=True)
|
||||
srv.serve_forever()
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
Quiet shim page for the desktop update hand-off (Windows + POSIX).
|
||||
|
||||
Served over loopback by the orchestrator (windows.ps1 / posix.sh) into a
|
||||
chromeless browser app window. Pure veneer: polls /progress for `done` or
|
||||
`error` and reacts; owns nothing (relaunch, result file, marker hygiene
|
||||
all live in the orchestrator, which runs identically with no UI at all).
|
||||
|
||||
The visual is PR #75895's update hand-off screen, ported verbatim:
|
||||
- Loader: the desktop's "Fourier Flow" curve. Math + tuning lifted from
|
||||
apps/bootstrap-installer/src/components/loader.tsx (itself lifted from
|
||||
apps/desktop/src/components/ui/loader.tsx 'fourier-flow'). Keep the
|
||||
constants in sync if the desktop's curve is retuned.
|
||||
- Layout: loader (size-20) + one title + one muted line. No progress bar,
|
||||
no stage list, no log pane, no cancel (see #75895 for the arguments).
|
||||
- Appearance follows the OS. Dark seeds are the installer's neutral
|
||||
charcoal (#232323 base, foreground #d6d6d6) — never brand blue.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hermes</title>
|
||||
<style>
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #1a1a1a;
|
||||
--muted-foreground: #737373;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #232323;
|
||||
--foreground: #d6d6d6;
|
||||
--muted-foreground: #8a8a8a;
|
||||
}
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: system-ui, 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* UpdateScreen: flex h-full flex-col items-center justify-center gap-4
|
||||
px-6 text-center (routes/progress.tsx) */
|
||||
.wrap {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 0 24px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
@keyframes hermes-fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.wrap { animation: hermes-fade-in 0.45s ease-out both; }
|
||||
/* Loader size-20 (5rem); svg overflow-visible; curve path opacity .1 */
|
||||
#loader { width: 80px; height: 80px; color: var(--foreground); }
|
||||
#loader svg { width: 100%; height: 100%; overflow: visible; }
|
||||
#glyph {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 44px;
|
||||
line-height: 1;
|
||||
}
|
||||
/* text-lg font-semibold tracking-tight */
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
/* text-xs text-muted-foreground */
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 80%;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
p code {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
body.done #loader, body.error #loader { display: none; }
|
||||
body.done #glyph, body.error #glyph { display: flex; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div id="loader" role="status" aria-label="Updating"></div>
|
||||
<div id="glyph"></div>
|
||||
<h2 id="title">Updating Hermes</h2>
|
||||
<p id="line">Hermes will open once done.</p>
|
||||
</div>
|
||||
<script>
|
||||
/* ── Fourier Flow loader, ported verbatim from loader.tsx ─────────────── */
|
||||
const TWO_PI = Math.PI * 2
|
||||
|
||||
const CURVE = {
|
||||
durationMs: 2200,
|
||||
particleCount: 92,
|
||||
pulseDurationMs: 2000,
|
||||
strokeWidth: 4.2,
|
||||
trailSpan: 0.31,
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const mix = 1 + detailScale * 0.16
|
||||
const x = 17 * Math.cos(t) + 7.5 * Math.cos(3 * t + 0.6 * mix) + 3.2 * Math.sin(5 * t - 0.4)
|
||||
const y = 15 * Math.sin(t) + 8.2 * Math.sin(2 * t + 0.25) - 4.2 * Math.cos(4 * t - 0.5 * mix)
|
||||
|
||||
return { x: 50 + x, y: 50 + y }
|
||||
}
|
||||
}
|
||||
|
||||
const PATH_STEPS = 240
|
||||
const norm = progress => ((progress % 1) + 1) % 1
|
||||
|
||||
function detailScaleFor(time, phaseOffset) {
|
||||
const p = ((time + phaseOffset * CURVE.pulseDurationMs) % CURVE.pulseDurationMs) / CURVE.pulseDurationMs
|
||||
|
||||
return 0.52 + ((Math.sin(p * TWO_PI + 0.55) + 1) / 2) * 0.48
|
||||
}
|
||||
|
||||
function buildPath(detailScale, steps) {
|
||||
return Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const { x, y } = CURVE.point(i / steps, detailScale)
|
||||
|
||||
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
function particleFor(index, progress, detailScale) {
|
||||
const tail = index / (CURVE.particleCount - 1)
|
||||
const { x, y } = CURVE.point(norm(progress - tail * CURVE.trailSpan), detailScale)
|
||||
const fade = (1 - tail) ** 0.56
|
||||
|
||||
return { x, y, opacity: 0.04 + fade * 0.96, radius: 0.9 + fade * 2.7 }
|
||||
}
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
const svg = document.createElementNS(SVG_NS, 'svg')
|
||||
svg.setAttribute('viewBox', '0 0 100 100')
|
||||
svg.setAttribute('fill', 'none')
|
||||
svg.setAttribute('aria-hidden', 'true')
|
||||
const curvePath = document.createElementNS(SVG_NS, 'path')
|
||||
curvePath.setAttribute('opacity', '0.1')
|
||||
curvePath.setAttribute('stroke', 'currentColor')
|
||||
curvePath.setAttribute('stroke-linecap', 'round')
|
||||
curvePath.setAttribute('stroke-linejoin', 'round')
|
||||
curvePath.setAttribute('stroke-width', String(CURVE.strokeWidth))
|
||||
svg.appendChild(curvePath)
|
||||
const particles = Array.from({ length: CURVE.particleCount }, () => {
|
||||
const c = document.createElementNS(SVG_NS, 'circle')
|
||||
c.setAttribute('fill', 'currentColor')
|
||||
svg.appendChild(c)
|
||||
|
||||
return c
|
||||
})
|
||||
document.getElementById('loader').appendChild(svg)
|
||||
|
||||
let frame = 0
|
||||
const startedAt = performance.now()
|
||||
const phaseOffset = Math.random()
|
||||
|
||||
function render(now) {
|
||||
const time = now - startedAt
|
||||
const progress = ((time + phaseOffset * CURVE.durationMs) % CURVE.durationMs) / CURVE.durationMs
|
||||
const detailScale = detailScaleFor(time, phaseOffset)
|
||||
|
||||
curvePath.setAttribute('d', buildPath(detailScale, PATH_STEPS))
|
||||
particles.forEach((node, index) => {
|
||||
const p = particleFor(index, progress, detailScale)
|
||||
node.setAttribute('cx', p.x.toFixed(2))
|
||||
node.setAttribute('cy', p.y.toFixed(2))
|
||||
node.setAttribute('r', p.radius.toFixed(2))
|
||||
node.setAttribute('opacity', p.opacity.toFixed(3))
|
||||
})
|
||||
|
||||
frame = window.requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
render(performance.now())
|
||||
|
||||
/* ── Event listener: done | error, nothing else ───────────────────────── */
|
||||
const titleEl = document.getElementById('title')
|
||||
const lineEl = document.getElementById('line')
|
||||
const glyphEl = document.getElementById('glyph')
|
||||
let settled = false
|
||||
|
||||
function settle(state) {
|
||||
settled = true
|
||||
window.cancelAnimationFrame(frame)
|
||||
document.body.className = state
|
||||
}
|
||||
|
||||
function apply(state) {
|
||||
if (settled) return
|
||||
if (state.status === 'done') {
|
||||
settle('done')
|
||||
glyphEl.textContent = '\u2713'
|
||||
lineEl.textContent = 'Opening Hermes\u2026'
|
||||
} else if (state.status === 'manual') {
|
||||
// Update landed but Hermes will NOT reopen itself (package skew,
|
||||
// sandbox helper, launch rejected). The orchestrator leaves this
|
||||
// window up; the message says what to do.
|
||||
settle('done')
|
||||
glyphEl.textContent = '\u2713'
|
||||
titleEl.textContent = 'Update complete'
|
||||
lineEl.textContent = state.message || 'Reopen Hermes to finish.'
|
||||
} else if (state.status === 'error') {
|
||||
settle('error')
|
||||
glyphEl.textContent = '\u2715'
|
||||
titleEl.textContent = 'Failed to update'
|
||||
lineEl.innerHTML = 'Run <code>hermes debug share</code> in a terminal to send a report.'
|
||||
}
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch('/progress', { cache: 'no-store' })
|
||||
if (res.ok) apply(await res.json())
|
||||
} catch {
|
||||
// Server gone: hold the last known state. The orchestrator owns
|
||||
// closing this window; the relaunched Desktop owns the result.
|
||||
}
|
||||
if (!settled) setTimeout(poll, 400)
|
||||
}
|
||||
poll()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,744 @@
|
|||
# windows.ps1 -- repo-owned Windows Desktop update hand-off.
|
||||
#
|
||||
# WHY THIS EXISTS (the frozen-binary problem): the Desktop's Update button
|
||||
# used to hand off exclusively to the staged Tauri binary
|
||||
# (%HERMES_HOME%\hermes-setup.exe). That binary has no self-update path --
|
||||
# copy_self_to_hermes_home deliberately no-ops during --update -- so every
|
||||
# updater-side fix (cache refresh #67369, marker self-adopt #74782, straggler
|
||||
# handling) only reaches users when a new installer is built, signed, and
|
||||
# published. In practice binaries go months stale and users hit long-fixed
|
||||
# bugs on every update (the 2026-08-09 incident chain).
|
||||
#
|
||||
# This script lives in the repo checkout, so EVERY `hermes update` refreshes
|
||||
# the very code that drives the next update. The Desktop spawns it through a
|
||||
# `cmd start` wrapper (see wrapHandoffForDetachedConsole in
|
||||
# apps/desktop/electron/updater-process.ts -- a bare detached+hidden
|
||||
# powershell dies before -File runs) and exits; only PowerShell itself -- an
|
||||
# OS component -- is "frozen".
|
||||
#
|
||||
# CONTRACT (keep in sync with apps/desktop/electron/main.ts):
|
||||
# cmd /d /s /c start "" /min powershell -NoProfile -ExecutionPolicy Bypass
|
||||
# -File scripts\desktop-update\windows.ps1
|
||||
# -InstallRoot <path> repo checkout (HERMES_HOME\hermes-agent)
|
||||
# -Branch <ref> branch to update against
|
||||
# -DesktopPid <pid> the Electron main process to wait out
|
||||
# [-RelaunchExe <path>] Hermes.exe to start when done (omit = no relaunch)
|
||||
# [-NoUi] headless (tests); default shows a progress window
|
||||
# [-NoMarkerCleanup] leave .hermes-update-in-progress in place (tests)
|
||||
#
|
||||
# SAFETY POSTURE: both preflight gates FAIL CLOSED. A Desktop that never
|
||||
# exits, or a venv shim that never unlocks, aborts the hand-off without
|
||||
# mutating the install -- a skipped update is recoverable, a half-updated
|
||||
# venv is not. Every exit path (success, abort, crash) writes
|
||||
# .hermes-update-result.json for the relaunched Desktop to surface, and
|
||||
# relaunches the Desktop so the user is never left stranded.
|
||||
#
|
||||
# Marker: we claim HERMES_HOME\.hermes-update-in-progress with OUR pid as
|
||||
# step 0 (the wrapper cmd.exe pid the Desktop saw is useless -- it exits
|
||||
# immediately). hermes_cli/update_lock.py's ancestry rule lets our
|
||||
# `hermes update` child adopt the claim; electron/update-marker.ts parks a
|
||||
# relaunched Desktop on it. Cleanup only removes the marker while WE still
|
||||
# own it (a handoff partner that rewrote it keeps its claim).
|
||||
|
||||
param(
|
||||
[string]$InstallRoot,
|
||||
[string]$Branch = "main",
|
||||
[int]$DesktopPid = 0,
|
||||
[string]$RelaunchExe = "",
|
||||
[switch]$NoUi,
|
||||
[switch]$NoMarkerCleanup,
|
||||
[switch]$SelfTestUi
|
||||
)
|
||||
|
||||
if (-not $SelfTestUi -and -not $InstallRoot) {
|
||||
# Mandatory in spirit; relaxed in the signature only so -SelfTestUi can
|
||||
# drive the UI without a checkout.
|
||||
throw "-InstallRoot is required"
|
||||
}
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
# Foreground helpers: the script is spawned via `cmd start /min`, so its
|
||||
# WinForms window comes up backgrounded unless we explicitly claim focus --
|
||||
# and after the update we must hand focus TO the relaunched Desktop (a
|
||||
# WMI-spawned process starts unfocused). AllowSetForegroundWindow lets us
|
||||
# pass our foreground right on to the new Hermes.exe pid.
|
||||
try {
|
||||
Add-Type -Namespace HermesHandoff -Name Win32 -MemberDefinition @'
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int dwProcessId);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow);
|
||||
'@ -ErrorAction Stop
|
||||
$script:Win32 = $true
|
||||
} catch { $script:Win32 = $false }
|
||||
# Render UTF-8 glyphs (checkmarks, arrows) correctly in our own console echo
|
||||
# too; the legacy conhost default OEM codepage shows them as mojibake.
|
||||
try {
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
} catch {}
|
||||
$TempDir = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
|
||||
$HermesHome = if ($InstallRoot) { Split-Path -Parent $InstallRoot } else { $TempDir }
|
||||
$MarkerPath = Join-Path $HermesHome ".hermes-update-in-progress"
|
||||
$LogDir = Join-Path $HermesHome "logs"
|
||||
$LogPath = Join-Path $LogDir "desktop-update-handoff.log"
|
||||
$ResultPath = Join-Path $HermesHome ".hermes-update-result.json"
|
||||
$script:Ui = $null
|
||||
|
||||
function Write-HandoffLog([string]$Message) {
|
||||
$line = "{0:yyyy-MM-ddTHH:mm:ssK} {1}" -f (Get-Date), $Message
|
||||
try { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } catch {}
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
# ── The shim: repo-owned HTML in a chromeless Edge app window ──────────────
|
||||
# The window is a veneer, not a participant: the update runs identically with
|
||||
# or without it (Edge missing/failed degrades to the WinForms card below,
|
||||
# then log-only). It streams nothing and knows nothing — it polls /progress
|
||||
# for one of two events, `done` or `error`, and reacts. The loopback listener
|
||||
# is not a web server in any meaningful sense; it exists because file:// pages
|
||||
# cannot receive events from a detached process. Salvaged from the web-shell
|
||||
# spike (Co-authored-by: teknium1), reshaped to the quiet update-surface
|
||||
# contract (#75895/#83634): loader, one title, one line, no dashboard.
|
||||
$script:UiState = [hashtable]::Synchronized(@{
|
||||
status = "running" # running | done | error
|
||||
message = ""
|
||||
})
|
||||
$script:UiServer = $null # @{ Listener; Runspace; PowerShell; Port; EdgeProc }
|
||||
|
||||
function Get-UiHtmlPath {
|
||||
# Lives next to this script in the checkout. Missing file = fall back to
|
||||
# WinForms (old checkouts mid-update, partial syncs).
|
||||
$p = Join-Path $PSScriptRoot "ui.html"
|
||||
if (Test-Path -LiteralPath $p) { return $p }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Find-EdgeExe {
|
||||
foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:LOCALAPPDATA)) {
|
||||
if (-not $root) { continue }
|
||||
$p = Join-Path $root "Microsoft\Edge\Application\msedge.exe"
|
||||
if (Test-Path -LiteralPath $p) { return $p }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Start-UiServer([string]$HtmlPath) {
|
||||
# In-process HTTP on a loopback ephemeral port, served from a dedicated
|
||||
# runspace so the main thread never blocks on Accept. Plain TcpListener
|
||||
# instead of HttpListener: no URL ACL / netsh reservation semantics to
|
||||
# trip over, and two GET routes don't need more.
|
||||
try {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
||||
$listener.Start()
|
||||
$port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
|
||||
$rs = [runspacefactory]::CreateRunspace()
|
||||
$rs.Open()
|
||||
$rs.SessionStateProxy.SetVariable("Listener", $listener)
|
||||
$rs.SessionStateProxy.SetVariable("State", $script:UiState)
|
||||
$rs.SessionStateProxy.SetVariable("HtmlBytes", [System.IO.File]::ReadAllBytes($HtmlPath))
|
||||
|
||||
$ps = [powershell]::Create()
|
||||
$ps.Runspace = $rs
|
||||
[void]$ps.AddScript({
|
||||
function Send-Response($Stream, [string]$Status, [string]$ContentType, [byte[]]$Body) {
|
||||
$head = "HTTP/1.1 $Status`r`nContent-Type: $ContentType`r`nContent-Length: $($Body.Length)`r`nCache-Control: no-store`r`nConnection: close`r`n`r`n"
|
||||
$headBytes = [System.Text.Encoding]::ASCII.GetBytes($head)
|
||||
$Stream.Write($headBytes, 0, $headBytes.Length)
|
||||
$Stream.Write($Body, 0, $Body.Length)
|
||||
$Stream.Flush()
|
||||
}
|
||||
while ($true) {
|
||||
try { $client = $Listener.AcceptTcpClient() } catch { break } # Stop() ends the loop
|
||||
try {
|
||||
$client.ReceiveTimeout = 2000
|
||||
$stream = $client.GetStream()
|
||||
$reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::ASCII, $false, 1024, $true)
|
||||
$request = $reader.ReadLine()
|
||||
# Drain headers so the client doesn't see a reset mid-send.
|
||||
while ($true) { $h = $reader.ReadLine(); if ($null -eq $h -or $h -eq "") { break } }
|
||||
if ($request -match "^GET /progress") {
|
||||
$snapshot = @{
|
||||
status = $State.status
|
||||
message = $State.message
|
||||
} | ConvertTo-Json -Compress
|
||||
Send-Response $stream "200 OK" "application/json; charset=utf-8" ([System.Text.Encoding]::UTF8.GetBytes($snapshot))
|
||||
} elseif ($request -match "^GET / ") {
|
||||
Send-Response $stream "200 OK" "text/html; charset=utf-8" $HtmlBytes
|
||||
} else {
|
||||
Send-Response $stream "404 Not Found" "text/plain" ([System.Text.Encoding]::ASCII.GetBytes("not found"))
|
||||
}
|
||||
} catch {
|
||||
# Per-connection failure: drop it, keep serving.
|
||||
} finally {
|
||||
try { $client.Close() } catch {}
|
||||
}
|
||||
}
|
||||
})
|
||||
[void]$ps.BeginInvoke()
|
||||
|
||||
return @{ Listener = $listener; Runspace = $rs; PowerShell = $ps; Port = $port; EdgeProc = $null }
|
||||
} catch {
|
||||
try { if ($listener) { $listener.Stop() } } catch {}
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-UiServer([switch]$LeaveWindow) {
|
||||
if (-not $script:UiServer) { return }
|
||||
try { $script:UiServer.Listener.Stop() } catch {}
|
||||
try { $script:UiServer.PowerShell.Stop() } catch {}
|
||||
try { $script:UiServer.Runspace.Close() } catch {}
|
||||
# On success the window closes itself out from under the user (the whole
|
||||
# point); on error we LEAVE it — the page holds the failure state and the
|
||||
# user closes it when they've read it.
|
||||
if (-not $LeaveWindow) {
|
||||
try {
|
||||
if ($script:UiServer.EdgeProc -and -not $script:UiServer.EdgeProc.HasExited) {
|
||||
$script:UiServer.EdgeProc.CloseMainWindow() | Out-Null
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$script:UiServer = $null
|
||||
}
|
||||
|
||||
function Publish-UiEvent([string]$Status, [string]$Message) {
|
||||
# The event the shim listens for. One beat of poll latency (400ms) before
|
||||
# teardown so the page actually renders the terminal state.
|
||||
$script:UiState.message = $Message
|
||||
$script:UiState.status = $Status
|
||||
if ($script:UiServer) { Start-Sleep -Milliseconds 900 }
|
||||
}
|
||||
|
||||
# ── Fallback card (no Edge / no HTML): same shape in WinForms ──────────────
|
||||
# Matches the shim pixel-for-pixel in spirit -- loader, one title, one static
|
||||
# line, OS light/dark -- so degrading is invisible to the user.
|
||||
function Get-AppsUseLightTheme {
|
||||
try {
|
||||
$v = Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" -Name AppsUseLightTheme -ErrorAction Stop
|
||||
return [int]$v.AppsUseLightTheme -ne 0
|
||||
} catch { return $true }
|
||||
}
|
||||
|
||||
function Show-ProgressWindow {
|
||||
if ($NoUi) { return }
|
||||
|
||||
# ── Primary: the HTML shim in a chromeless Edge app window ─────────────
|
||||
# Same footprint as the card (280x320), spawned as a normal window: it
|
||||
# claims attention once by appearing, then competes with nothing.
|
||||
$htmlPath = Get-UiHtmlPath
|
||||
$edge = Find-EdgeExe
|
||||
if ($htmlPath -and $edge) {
|
||||
$server = Start-UiServer $htmlPath
|
||||
if ($server) {
|
||||
try {
|
||||
# Dedicated tiny profile dir: guarantees a NEW WINDOW + process
|
||||
# we own (a default-profile launch delegates to an existing
|
||||
# Edge and returns instantly, leaving nothing to close), and
|
||||
# avoids touching the user's real browser profile.
|
||||
$edgeProfile = Join-Path $TempDir ("hermes-update-ui-{0}" -f $PID)
|
||||
$edgeArgs = @(
|
||||
"--app=http://127.0.0.1:$($server.Port)/",
|
||||
"--user-data-dir=$edgeProfile",
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--disable-features=msImplicitSignin",
|
||||
"--window-size=280,320"
|
||||
)
|
||||
$server.EdgeProc = Start-Process -FilePath $edge -ArgumentList $edgeArgs -PassThru
|
||||
$script:UiServer = $server
|
||||
Write-HandoffLog "shim: Edge app window on 127.0.0.1:$($server.Port)"
|
||||
return
|
||||
} catch {
|
||||
try { $server.Listener.Stop() } catch {}
|
||||
# fall through to WinForms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Add-Type -AssemblyName System.Windows.Forms | Out-Null
|
||||
Add-Type -AssemblyName System.Drawing | Out-Null
|
||||
$light = Get-AppsUseLightTheme
|
||||
# Dark seeds are the settled installer palette: neutral charcoal,
|
||||
# never brand blue.
|
||||
if ($light) {
|
||||
$back = [System.Drawing.Color]::White
|
||||
$fore = [System.Drawing.ColorTranslator]::FromHtml("#1A1A1A")
|
||||
$mute = [System.Drawing.ColorTranslator]::FromHtml("#6B6B6B")
|
||||
} else {
|
||||
$back = [System.Drawing.ColorTranslator]::FromHtml("#232323")
|
||||
$fore = [System.Drawing.ColorTranslator]::FromHtml("#F5F5F5")
|
||||
$mute = [System.Drawing.ColorTranslator]::FromHtml("#A8A8A8")
|
||||
}
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "Hermes"
|
||||
$form.FormBorderStyle = "FixedSingle"
|
||||
$form.MaximizeBox = $false
|
||||
$form.MinimizeBox = $false
|
||||
$form.ControlBox = $false
|
||||
$form.ClientSize = New-Object System.Drawing.Size(280, 320)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
$form.BackColor = $back
|
||||
|
||||
$bar = New-Object System.Windows.Forms.ProgressBar
|
||||
$bar.Style = "Marquee"
|
||||
$bar.MarqueeAnimationSpeed = 30
|
||||
$bar.SetBounds(60, 128, 160, 8)
|
||||
$title = New-Object System.Windows.Forms.Label
|
||||
$title.Text = "Updating Hermes"
|
||||
$title.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 12)
|
||||
$title.ForeColor = $fore
|
||||
$title.TextAlign = "MiddleCenter"
|
||||
$title.SetBounds(16, 156, 248, 28)
|
||||
$sub = New-Object System.Windows.Forms.Label
|
||||
$sub.Text = "Hermes will open once done."
|
||||
$sub.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||||
$sub.ForeColor = $mute
|
||||
$sub.TextAlign = "TopCenter"
|
||||
$sub.SetBounds(24, 190, 232, 48)
|
||||
$form.Controls.Add($bar)
|
||||
$form.Controls.Add($title)
|
||||
$form.Controls.Add($sub)
|
||||
$form.Show()
|
||||
# `cmd start /min` spawned us backgrounded, so the card comes up
|
||||
# behind everything without one explicit activation. Claim it ONCE
|
||||
# (so the user knows the update started), then never again — the
|
||||
# window is decoration and competes with nothing (no TopMost).
|
||||
try {
|
||||
$form.Activate()
|
||||
if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($form.Handle) | Out-Null }
|
||||
} catch {}
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
$script:Ui = [pscustomobject]@{ Form = $form; Bar = $bar; Title = $title; Sub = $sub }
|
||||
} catch {
|
||||
# Headless session / WinForms unavailable: degrade to log-only.
|
||||
$script:Ui = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Show-ErrorFinale([string]$Message) {
|
||||
# Terse by design: a title + the debug-share pointer. No error text, no
|
||||
# log tail -- `hermes debug share` uploads the real evidence and the
|
||||
# relaunched Desktop surfaces the result message.
|
||||
if ($script:UiServer) {
|
||||
# The shim renders the error state itself; leave the window up for
|
||||
# the user to read and close. Nothing to hold for — the page keeps
|
||||
# the state after the listener dies.
|
||||
Publish-UiEvent "error" $Message
|
||||
Stop-UiServer -LeaveWindow
|
||||
return
|
||||
}
|
||||
if (-not $script:Ui) { return }
|
||||
try {
|
||||
$ui = $script:Ui
|
||||
$ui.Bar.Visible = $false
|
||||
$ui.Title.Text = "Failed to update"
|
||||
$ui.Sub.Text = "Run `"hermes debug share`" in a terminal to send a report."
|
||||
$close = New-Object System.Windows.Forms.Button
|
||||
$close.Text = "Close"
|
||||
$close.SetBounds(100, 252, 80, 28)
|
||||
$close.FlatStyle = "Flat"
|
||||
$close.ForeColor = $ui.Title.ForeColor
|
||||
$script:ErrorDismissed = $false
|
||||
$close.Add_Click({ $script:ErrorDismissed = $true })
|
||||
$ui.Form.Controls.Add($close)
|
||||
$ui.Form.AcceptButton = $close
|
||||
try {
|
||||
$ui.Form.Activate()
|
||||
if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($ui.Form.Handle) | Out-Null }
|
||||
} catch {}
|
||||
# Hold for dismissal so the failure is actually seen, but never park
|
||||
# forever -- the marker is already cleaned up and the relaunched
|
||||
# Desktop re-surfaces the failure, so walking away costs nothing.
|
||||
$deadline = (Get-Date).AddMinutes(5)
|
||||
while (-not $script:ErrorDismissed -and (Get-Date) -lt $deadline -and $ui.Form.Visible) {
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Show-ManualFinale([string]$Message) {
|
||||
# Update landed but the Desktop did not verifiably come back. Same terse
|
||||
# shape as the error finale, success glyph semantics: the shim renders
|
||||
# `manual` itself; the WinForms card swaps its copy. Held so the user
|
||||
# actually sees the instruction — this window is the only surface until
|
||||
# they reopen Hermes themselves.
|
||||
if ($script:UiServer) {
|
||||
Publish-UiEvent "manual" $Message
|
||||
Stop-UiServer -LeaveWindow
|
||||
return
|
||||
}
|
||||
if (-not $script:Ui) { return }
|
||||
try {
|
||||
$ui = $script:Ui
|
||||
$ui.Bar.Visible = $false
|
||||
$ui.Title.Text = "Update complete"
|
||||
$ui.Sub.Text = $Message
|
||||
$close = New-Object System.Windows.Forms.Button
|
||||
$close.Text = "Close"
|
||||
$close.SetBounds(100, 252, 80, 28)
|
||||
$close.FlatStyle = "Flat"
|
||||
$close.ForeColor = $ui.Title.ForeColor
|
||||
$script:ErrorDismissed = $false
|
||||
$close.Add_Click({ $script:ErrorDismissed = $true })
|
||||
$ui.Form.Controls.Add($close)
|
||||
$ui.Form.AcceptButton = $close
|
||||
try {
|
||||
$ui.Form.Activate()
|
||||
if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($ui.Form.Handle) | Out-Null }
|
||||
} catch {}
|
||||
$deadline = (Get-Date).AddMinutes(5)
|
||||
while (-not $script:ErrorDismissed -and (Get-Date) -lt $deadline -and $ui.Form.Visible) {
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Close-ProgressWindow {
|
||||
if ($script:UiServer) {
|
||||
# Success event: the shim flips to the checkmark, then the window
|
||||
# closes out from under the user as the Desktop comes back.
|
||||
Publish-UiEvent "done" ""
|
||||
Stop-UiServer
|
||||
}
|
||||
if ($script:Ui) {
|
||||
try { $script:Ui.Form.Close() } catch {}
|
||||
$script:Ui = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Result([bool]$Ok, [int]$Code, [string]$Message, [bool]$ManualAction = $false) {
|
||||
# Consumed (read + deleted) by the relaunched Desktop on boot so the
|
||||
# user actually SEES how a detached update ended. $ManualAction marks an
|
||||
# ok result the user still must act on -- the Desktop surfaces those in
|
||||
# a dialog, not just the log (same protocol as posix.sh).
|
||||
try {
|
||||
$obj = @{
|
||||
ok = $Ok
|
||||
exit_code = $Code
|
||||
manual = $ManualAction
|
||||
message = $Message
|
||||
branch = $Branch
|
||||
finished_at = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
} | ConvertTo-Json -Compress
|
||||
[System.IO.File]::WriteAllText($ResultPath, $obj)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Remove-MarkerIfOwned {
|
||||
if ($NoMarkerCleanup) { return }
|
||||
try {
|
||||
if (Test-Path -LiteralPath $MarkerPath) {
|
||||
$firstLine = (Get-Content -LiteralPath $MarkerPath -TotalCount 1 -ErrorAction SilentlyContinue)
|
||||
if ("$firstLine".Trim() -eq "$PID") {
|
||||
Remove-Item -LiteralPath $MarkerPath -Force -ErrorAction SilentlyContinue
|
||||
Write-HandoffLog "removed update marker (owned)"
|
||||
} else {
|
||||
Write-HandoffLog "leaving update marker: owned by pid '$firstLine', not us ($PID)"
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Start-DesktopRelaunch {
|
||||
# Returns $true only when a launch VERIFIABLY happened (WMI accepted and
|
||||
# the pid exists, or the fallback spawn returned a live process). The
|
||||
# finally block downgrades the on-screen/on-disk outcome when it didn't
|
||||
# — the sibling truth contract to posix.sh's launch acceptance.
|
||||
if (-not ($RelaunchExe -and (Test-Path -LiteralPath $RelaunchExe))) { return $false }
|
||||
Write-HandoffLog "relaunching desktop: $RelaunchExe"
|
||||
# DO NOT spawn Hermes.exe as our child: Electron/Chromium calls
|
||||
# AttachConsole(ATTACH_PARENT_PROCESS) at boot, so a Desktop launched
|
||||
# directly from this console PowerShell latches onto OUR console --
|
||||
# the console window then outlives the script (it can't close while
|
||||
# an attached process lives), and closing it kills the freshly
|
||||
# relaunched GUI with it. Create the process via WMI instead: the
|
||||
# parent becomes WmiPrvSE.exe and there is no console to inherit or
|
||||
# attach -- same detachment explorer.exe gives a normal launch.
|
||||
$spawned = $false
|
||||
try {
|
||||
$workDir = Split-Path -Parent $RelaunchExe
|
||||
$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
||||
CommandLine = ('"{0}"' -f $RelaunchExe)
|
||||
CurrentDirectory = $workDir
|
||||
} -ErrorAction Stop
|
||||
if ($r -and $r.ReturnValue -eq 0) {
|
||||
Write-HandoffLog "desktop relaunched detached (pid $($r.ProcessId))"
|
||||
$spawned = $true
|
||||
# Hand our foreground rights to the new Desktop and focus its
|
||||
# main window once it exists. A WMI-spawned process starts
|
||||
# unfocused, and Windows only lets the CURRENT foreground
|
||||
# owner (us, while the progress window is up / just closed)
|
||||
# delegate that right. Poll briefly for the window: Electron
|
||||
# takes a couple seconds to create it.
|
||||
try {
|
||||
if ($script:Win32) {
|
||||
[HermesHandoff.Win32]::AllowSetForegroundWindow([int]$r.ProcessId) | Out-Null
|
||||
$deadline = (Get-Date).AddSeconds(20)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$hwnd = [System.IntPtr]::Zero
|
||||
try {
|
||||
$p = Get-Process -Id $r.ProcessId -ErrorAction Stop
|
||||
$hwnd = $p.MainWindowHandle
|
||||
} catch {
|
||||
# Process died before showing a window — that is a
|
||||
# failed launch, not merely an unfocused one.
|
||||
Write-HandoffLog "WARNING: relaunched desktop exited before its window appeared"
|
||||
$spawned = $false
|
||||
break
|
||||
}
|
||||
if ($hwnd -ne [System.IntPtr]::Zero) {
|
||||
[HermesHandoff.Win32]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE
|
||||
[HermesHandoff.Win32]::SetForegroundWindow($hwnd) | Out-Null
|
||||
Write-HandoffLog "focused relaunched desktop window"
|
||||
break
|
||||
}
|
||||
Start-Sleep -Milliseconds 400
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-HandoffLog "WARNING: could not focus relaunched desktop: $($_.Exception.Message)"
|
||||
}
|
||||
} else {
|
||||
Write-HandoffLog "WARNING: WMI relaunch returned $($r.ReturnValue); falling back"
|
||||
}
|
||||
} catch {
|
||||
Write-HandoffLog "WARNING: WMI relaunch failed: $($_.Exception.Message); falling back"
|
||||
}
|
||||
if (-not $spawned) {
|
||||
try {
|
||||
# Fallback keeps the old behavior (console tie-in and all) --
|
||||
# a tethered Desktop beats no Desktop.
|
||||
$p = Start-Process -FilePath $RelaunchExe -WorkingDirectory (Split-Path -Parent $RelaunchExe) -PassThru
|
||||
Start-Sleep -Milliseconds 1500
|
||||
if ($p -and -not $p.HasExited) { $spawned = $true }
|
||||
elseif ($p) { Write-HandoffLog "WARNING: fallback relaunch exited immediately" }
|
||||
} catch {
|
||||
Write-HandoffLog "WARNING: desktop relaunch failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
return $spawned
|
||||
}
|
||||
|
||||
function Invoke-HermesStep([string]$Exe, [string[]]$HermesArgs, [string]$Tag) {
|
||||
# The window shows nothing live, so no line-pump: both pipes drain
|
||||
# asynchronously (no deadlock however chatty the child) while a small
|
||||
# DoEvents loop keeps the marquee animating through long silent
|
||||
# stretches (pip installs) -- the old EndOfStream pump blocked on quiet
|
||||
# children and froze it. Full output still lands in the hand-off log
|
||||
# afterwards, where `hermes debug share` picks it up.
|
||||
# System.Diagnostics.Process directly: Start-Process's .ExitCode is
|
||||
# unreliably $null under PS 5.1 even with the Handle-touch workaround.
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
# .Arguments string (PS 5.1 / .NET Framework has no ArgumentList).
|
||||
# Args here are fixed flags + a branch ref; quote each defensively.
|
||||
$psi.Arguments = ($HermesArgs | ForEach-Object { '"{0}"' -f ($_ -replace '"', '\"') }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
# hermes update prints UTF-8 (checkmarks, arrows, box glyphs). PS 5.1
|
||||
# defaults these readers to the OEM codepage, which mangles every
|
||||
# multi-byte glyph into mojibake in the log.
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
|
||||
# And ask the child to actually EMIT UTF-8: Python decides its stdio
|
||||
# encoding from the console codepage when attached to one.
|
||||
$psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"
|
||||
$psi.EnvironmentVariables["PYTHONUTF8"] = "1"
|
||||
$psi.CreateNoWindow = $true
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$outTask = $proc.StandardOutput.ReadToEndAsync()
|
||||
$errTask = $proc.StandardError.ReadToEndAsync()
|
||||
while (-not $proc.HasExited) {
|
||||
Start-Sleep -Milliseconds 150
|
||||
if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
|
||||
}
|
||||
$proc.WaitForExit()
|
||||
$outText = $outTask.Result
|
||||
$errText = $errTask.Result
|
||||
foreach ($ln in ($outText -split "`r?`n")) {
|
||||
if ($ln.Trim()) { Write-HandoffLog ("{0}| {1}" -f $Tag, $ln) }
|
||||
}
|
||||
foreach ($ln in ($errText -split "`r?`n")) {
|
||||
if ($ln.Trim()) { Write-HandoffLog ("{0}!| {1}" -f $Tag, $ln) }
|
||||
}
|
||||
$all = $outText
|
||||
if ($errText) { $all += "`n" + $errText }
|
||||
return @{ Code = $proc.ExitCode; Output = $all }
|
||||
}
|
||||
|
||||
$finalCode = 1
|
||||
$finalMsg = "update did not complete"
|
||||
|
||||
# ── -SelfTestUi: drive the shim to both terminal states, no update ─────────
|
||||
# Manual QA for the Edge shell without a checkout or a real update. Exits
|
||||
# before the marker/desktop/venv machinery — touches nothing. Off Windows
|
||||
# (or without Edge) the loopback server still starts and the URL prints, so
|
||||
# the page can be QA'd in any browser; HERMES_SELFTEST_FAIL=1 exercises the
|
||||
# error state, HERMES_SELFTEST_HOLD_SECONDS delays the terminal event.
|
||||
if ($SelfTestUi) {
|
||||
New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
Show-ProgressWindow
|
||||
if (-not $script:UiServer) {
|
||||
$htmlPath = Get-UiHtmlPath
|
||||
if ($htmlPath) {
|
||||
$script:UiServer = Start-UiServer $htmlPath
|
||||
}
|
||||
}
|
||||
if ($script:UiServer) {
|
||||
Write-Host "SELF-TEST: shim at http://127.0.0.1:$($script:UiServer.Port)/"
|
||||
}
|
||||
Write-HandoffLog "SELF-TEST: shim simulation (no update will run)"
|
||||
$hold = 6
|
||||
if ($env:HERMES_SELFTEST_HOLD_SECONDS) { $hold = [int]$env:HERMES_SELFTEST_HOLD_SECONDS }
|
||||
Start-Sleep -Seconds $hold
|
||||
if ($env:HERMES_SELFTEST_FAIL) {
|
||||
Show-ErrorFinale "self-test error state"
|
||||
} else {
|
||||
Close-ProgressWindow
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue
|
||||
Show-ProgressWindow
|
||||
Write-HandoffLog "hand-off start: root=$InstallRoot branch=$Branch desktopPid=$DesktopPid pid=$PID"
|
||||
|
||||
# -- 0. Claim the update marker with OUR pid ---------------------------
|
||||
try {
|
||||
$epoch = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
# WriteAllText for byte-exact LF framing: Set-Content emits CRLF and
|
||||
# the marker contract (Rust/TS/Python readers) is "<pid>\n<ts>\n".
|
||||
[System.IO.File]::WriteAllText($MarkerPath, "$PID`n$epoch`n")
|
||||
Write-HandoffLog "claimed update marker (pid $PID)"
|
||||
} catch {
|
||||
Write-HandoffLog "WARNING: could not write update marker: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# -- 1. Wait for the Desktop to exit (FAIL CLOSED) ----------------------
|
||||
if ($DesktopPid -gt 0) {
|
||||
$deadline = (Get-Date).AddSeconds(30)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$proc = Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) { break }
|
||||
Start-Sleep -Milliseconds 300
|
||||
if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
|
||||
}
|
||||
if (Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue) {
|
||||
# A live Desktop means a live backend re-locking the venv at any
|
||||
# moment. Updating under it is how installs brick. Abort.
|
||||
$finalCode = 4
|
||||
$finalMsg = "Update aborted: the Hermes window (pid $DesktopPid) did not exit within 30s. Nothing was changed. Close Hermes fully and try again."
|
||||
Write-HandoffLog $finalMsg
|
||||
exit $finalCode
|
||||
}
|
||||
Write-HandoffLog "desktop exited"
|
||||
}
|
||||
|
||||
# -- 2. Wait for the venv shim to unlock (FAIL CLOSED) ------------------
|
||||
$shim = Join-Path $InstallRoot "venv\Scripts\hermes.exe"
|
||||
if (Test-Path -LiteralPath $shim) {
|
||||
$unlocked = $false
|
||||
$deadline = (Get-Date).AddSeconds(20)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$fs = [System.IO.File]::Open($shim, 'Open', 'ReadWrite', 'None')
|
||||
$fs.Close()
|
||||
$unlocked = $true
|
||||
break
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 400
|
||||
if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
|
||||
}
|
||||
}
|
||||
if (-not $unlocked) {
|
||||
# Something still maps the venv. --force-ing past it guarantees a
|
||||
# half-updated venv (the exact 2026-08-09 Access-denied brick).
|
||||
$finalCode = 5
|
||||
$finalMsg = "Update aborted: another process is still holding the Hermes install open (venv\Scripts\hermes.exe locked after 20s). Nothing was changed. Close other Hermes windows/terminals and try again."
|
||||
Write-HandoffLog $finalMsg
|
||||
exit $finalCode
|
||||
}
|
||||
Write-HandoffLog "venv shim unlocked"
|
||||
}
|
||||
|
||||
# -- 3. Run the update from the CURRENT checkout ------------------------
|
||||
# --force skips only the hermes.exe shim guard, which step 2 just PROVED
|
||||
# is unlocked; the venv-python holder guard (orphan reap included) stays
|
||||
# active. Our marker claim is adopted by the child via update_lock.py's
|
||||
# process-ancestry rule.
|
||||
$hermesExe = Join-Path $InstallRoot "venv\Scripts\hermes.exe"
|
||||
if (-not (Test-Path -LiteralPath $hermesExe)) {
|
||||
$finalCode = 3
|
||||
$finalMsg = "Update aborted: $hermesExe is missing. The install needs repair (run the Hermes installer or `hermes doctor`)."
|
||||
Write-HandoffLog $finalMsg
|
||||
exit $finalCode
|
||||
}
|
||||
$updateArgs = @("update", "--yes", "--gateway", "--force", "--branch", $Branch)
|
||||
Write-HandoffLog ("running: hermes " + ($updateArgs -join " "))
|
||||
$res = Invoke-HermesStep $hermesExe $updateArgs "update"
|
||||
Write-HandoffLog "hermes update exit code: $($res.Code)"
|
||||
|
||||
if ($res.Code -ne 0 -and $res.Code -ne 2) {
|
||||
# One retry for the update-boundary class (fresh code on disk, stale
|
||||
# code in memory). Exit 2 ("close all Hermes windows") is not retryable.
|
||||
Write-HandoffLog "first attempt failed; retrying once (freshly pulled fix loads on the second run)"
|
||||
$res = Invoke-HermesStep $hermesExe $updateArgs "update"
|
||||
Write-HandoffLog "retry exit code: $($res.Code)"
|
||||
}
|
||||
|
||||
# -- 4. Truthful completion: don't trust exit 0 -------------------------
|
||||
# `hermes update` treats a Desktop GUI build failure as NON-fatal (prints
|
||||
# a one-line warning, exits 0). For a Desktop-DRIVEN update that warning
|
||||
# is fatal: we would relaunch the old exe and call it success. Detect it,
|
||||
# retry the build once, and propagate honestly.
|
||||
$desktopBuildFailed = $false
|
||||
if ($res.Code -eq 0 -and $res.Output -match "Desktop build failed") {
|
||||
Write-HandoffLog "hermes update reported a desktop build failure (non-fatal there, fatal here); retrying build"
|
||||
$rebuild = Invoke-HermesStep $hermesExe @("desktop", "--force-build", "--build-only") "rebuild"
|
||||
Write-HandoffLog "desktop rebuild exit code: $($rebuild.Code)"
|
||||
if ($rebuild.Code -ne 0) { $desktopBuildFailed = $true }
|
||||
}
|
||||
|
||||
if ($res.Code -eq 0 -and -not $desktopBuildFailed) {
|
||||
$finalCode = 0
|
||||
$finalMsg = "Update complete."
|
||||
} elseif ($desktopBuildFailed) {
|
||||
$finalCode = 6
|
||||
$finalMsg = "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."
|
||||
} else {
|
||||
$finalCode = $res.Code
|
||||
$finalMsg = "Update failed (exit $($res.Code)). Run `hermes debug share` in a terminal to send a report."
|
||||
}
|
||||
exit $finalCode
|
||||
} finally {
|
||||
# Truth ordering (sibling contract to posix.sh finish()):
|
||||
# 1. durable result + marker removal (the relaunched Desktop consumes
|
||||
# the result on boot and must not park on our marker);
|
||||
# 2. attempt the relaunch and require ACCEPTANCE;
|
||||
# 3. only then the terminal UI state — done means "Hermes is back",
|
||||
# manual means "it is not, reopen it", error is error (and still
|
||||
# tries to bring the app back after showing itself).
|
||||
Write-Result ($finalCode -eq 0) $finalCode $finalMsg
|
||||
Remove-MarkerIfOwned
|
||||
if ($finalCode -ne 0) {
|
||||
Show-ErrorFinale $finalMsg
|
||||
Close-ProgressWindow
|
||||
[void](Start-DesktopRelaunch)
|
||||
} else {
|
||||
$cameBack = Start-DesktopRelaunch
|
||||
if (-not $cameBack -and $RelaunchExe) {
|
||||
# Launch was due and did not verifiably land: truthful result
|
||||
# for the next boot, manual state held on screen now.
|
||||
$finalMsg = "Update complete. Reopen Hermes to finish (it could not restart itself)."
|
||||
Write-Result $true 0 $finalMsg $true
|
||||
Show-ManualFinale $finalMsg
|
||||
}
|
||||
Close-ProgressWindow
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue