hermes-agent/apps/desktop/electron/updater-process.ts

227 lines
8.3 KiB
TypeScript

import { spawn, type SpawnOptions } from 'node:child_process'
import { statSync } from 'node:fs'
import path from 'node:path'
import { hiddenWindowsChildOptions } from './windows-child-options'
export interface UpdaterChild {
pid?: number
unref: () => void
}
export interface ResolveUpdateScriptHandoffDeps {
isWindows?: boolean
fileExists?: (candidate: string) => boolean
}
export interface UpdateScriptHandoff {
command: string
args: string[]
scriptPath: string
}
/**
* Repo-owned Windows update hand-off (frozen-binary escape hatch).
*
* The staged Tauri `hermes-setup.exe` has no self-update path, so every
* 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
* checkout instead: every `hermes update` refreshes the code that drives the
* NEXT update, and only PowerShell itself is frozen.
*
* Returns the spawn recipe when the script exists in the checkout, or null
* (caller falls back to the staged binary — old checkouts that predate the
* script keep working unchanged). Windows-only by the same policy as
* resolveStagedUpdaterBinary: POSIX updates in place via
* applyUpdatesPosixInApp and needs no hand-off at all.
*/
export function resolveUpdateScriptHandoff(
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.ps1')
const exists = deps.fileExists ?? stagedFileExists
if (!exists(scriptPath)) {
return null
}
return {
command: 'powershell',
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
scriptPath
}
}
/**
* Wrap a PowerShell hand-off invocation so it survives a detached, hidden
* spawn from Electron.
*
* Verified empirically (2026-08-09, Windows 11): `spawn('powershell', [...,
* '-File', script], { detached: true, stdio: 'ignore', windowsHide: true })`
* exits 0 WITHOUT executing a single line of the script. powershell.exe is a
* console-subsystem binary; detached+windowsHide gives it no console to
* attach to, and Windows PowerShell 5.1 dies during console init before
* -File processing (the same class of failure as #54220's conhost work, on
* the launch side). The same spawn with a visible console, or non-detached,
* runs fine — so unit tests and foreground use hide the bug.
*
* `cmd /c start "" /min powershell ...` was the variant that survived the
* full detached+hidden production shape in testing: `start` allocates the
* child its own (minimized) console and fully detaches it from cmd.exe,
* which exits immediately. The spawned pid is therefore the WRAPPER's —
* callers must not use it as a marker owner (the script claims the marker
* itself with its own $PID).
*/
export function wrapHandoffForDetachedConsole(
handoff: UpdateScriptHandoff,
extraArgs: string[]
): {
command: string
args: string[]
} {
return {
command: 'cmd.exe',
args: ['/d', '/s', '/c', 'start', '', '/min', handoff.command, ...handoff.args, ...extraArgs]
}
}
export interface ResolveStagedUpdaterBinaryDeps {
isWindows?: boolean
fileExists?: (candidate: string) => boolean
stagedMtimeMs?: (candidate: string) => number | null
}
/**
* Staged installers older than this have no self-PID exclusion in
* `UpdateMarkerGuard::acquire` and will refuse an update whose marker was
* pre-written on their behalf.
*
* The self-adopt fix landed in #74782 / 160586ff8 (2026-07-30 17:57 +0700).
* We compare against the start of 2026-07-31 UTC so the boundary is
* unambiguous for binaries staged that same day.
*/
export const MARKER_SELF_ADOPT_EPOCH_MS = Date.UTC(2026, 6, 31)
function stagedFileExists(candidate: string): boolean {
try {
return statSync(candidate).isFile()
} catch {
return false
}
}
function stagedFileMtimeMs(candidate: string): number | null {
try {
return statSync(candidate).mtimeMs
} catch {
return null
}
}
/**
* Decide which staged installer binary — if any — may be handed an update.
*
* The Tauri installer self-copies into HERMES_HOME on *every* platform
* (`hermes-setup.exe` on Windows, `hermes-setup` elsewhere — see
* apps/bootstrap-installer `paths::installer_dest` and
* `bootstrap::copy_self_to_hermes_home`), so finding that binary on macOS or
* Linux is expected, not leftover junk.
*
* Handing an update to it is nonetheless a Windows-only policy. Windows needs
* the quit -> hand-off -> rebuild dance because a venv shim file lock keeps the
* running desktop from rewriting its own bits; macOS and Linux have no such
* lock and update in place through applyUpdatesPosixInApp(). Off Windows the
* hand-off therefore buys nothing and costs a great deal: a staged binary older
* than the hand-off protocol holds the update marker, spawns `hermes update`,
* and that child refuses its own parent — wedging the in-app Update button for
* good, with no route (update, re-download, reinstall) to a newer binary
* (#74836). Returning null off Windows is what routes those platforms to the
* in-app updater.
*
* Null on Windows too when nothing is staged (a dev/source run, or a CLI
* install that never went through the installer); callers degrade gracefully.
*/
export function resolveStagedUpdaterBinary(
hermesHome: string,
deps: ResolveStagedUpdaterBinaryDeps = {}
): string | null {
const isWindows = deps.isWindows ?? process.platform === 'win32'
if (!isWindows) {
return null
}
const fileExists = deps.fileExists ?? stagedFileExists
const candidate = path.join(hermesHome, 'hermes-setup.exe')
return fileExists(candidate) ? candidate : null
}
/**
* True when the staged installer is new enough to survive a pre-written marker.
*
* `copy_self_to_hermes_home` deliberately no-ops during `--update`
* (apps/bootstrap-installer/src-tauri/src/paths.rs), so the binary staged by a
* user's ORIGINAL install orchestrates every later update — forever. Installers
* predating #74782 have no self-PID exclusion in `UpdateMarkerGuard::acquire`,
* so when the desktop pre-writes the marker naming that very updater, the
* updater reads its own claim as a foreign live owner and aborts with
* "Another Hermes update is already running (PID <itself>, started 1s ago)" —
* the observed infinite "Install didn't finish" loop. Skipping the pre-write
* for those binaries lets them acquire cleanly and run `hermes update`, which
* pulls the permanent fixes. See shouldPrewriteUpdateMarker.
*
* We cannot ask the binary its version without executing it, so use its mtime:
* the installer is written to HERMES_HOME at install/repair time, making mtime
* a faithful stamp of which installer generation produced it.
*
* Unreadable mtime counts as UNSUPPORTED — the pre-write is a best-effort
* hardening, while a wedged updater is unrecoverable, so we bias toward the
* path that can always make progress.
*/
export function stagedUpdaterSupportsPrewrittenMarker(
candidate: string,
deps: ResolveStagedUpdaterBinaryDeps = {}
): boolean {
const mtimeMs = (deps.stagedMtimeMs ?? stagedFileMtimeMs)(candidate)
return typeof mtimeMs === 'number' && Number.isFinite(mtimeMs) && mtimeMs >= MARKER_SELF_ADOPT_EPOCH_MS
}
export interface SpawnUpdaterProcessDeps {
isWindows?: boolean
spawnProcess?: (command: string, args: string[], options: SpawnOptions) => UpdaterChild
}
/**
* Spawn the detached installer used for update and bootstrap-recovery handoffs.
* The helper owns both hidden-console selection and unref semantics so every
* updater handoff follows the same behavior and can be tested without Electron.
*/
export function spawnUpdaterProcess(
updater: string,
updaterArgs: string[],
options: SpawnOptions,
deps: SpawnUpdaterProcessDeps = {}
): UpdaterChild {
const isWindows = deps.isWindows ?? process.platform === 'win32'
const spawnOptions = hiddenWindowsChildOptions(options, isWindows) as SpawnOptions
const child = deps.spawnProcess
? deps.spawnProcess(updater, updaterArgs, spawnOptions)
: spawn(updater, updaterArgs, spawnOptions)
child.unref()
return child
}