From 854ab7f6df3f1a3ea1aaaa6767bd538c8b6fdcda Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 21:38:42 -0500 Subject: [PATCH 01/11] fix(desktop/windows): quiet minimal update hand-off window The hand-off script's WinForms window was a 720x420 dashboard: streaming log box, wide marquee, warning label. Updating is a wait, not a dashboard -- it is now the same shape as the other update surfaces (#75895): a fixed 280x320 panel, marquee loader, one title, one static line, following the OS light/dark theme (charcoal #232323 seeds, never brand blue). Failure gets a terse finale instead of a wall of log: 'Failed to update' + 'Run "hermes debug share" in a terminal to send a report' + Close (held max 5 minutes, then the relaunched Desktop re-surfaces the result banner as before). The result-json message points at debug share too. With nothing streamed to the window, the per-line stdout pump is gone: Invoke-HermesStep drains both pipes async (no deadlock on chatty children, no frozen marquee on quiet ones) and writes full output to the hand-off log afterwards, where hermes debug share picks it up. --- scripts/desktop-update.ps1 | 178 +++++++++++++++++++++++-------------- 1 file changed, 109 insertions(+), 69 deletions(-) diff --git a/scripts/desktop-update.ps1 b/scripts/desktop-update.ps1 index d45484501db33..98024b1a0790f 100644 --- a/scripts/desktop-update.ps1 +++ b/scripts/desktop-update.ps1 @@ -80,12 +80,20 @@ 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 {} - } +} + +# The window is a veneer, not a participant: the update runs identically with +# or without it (any WinForms failure degrades to log-only), it streams +# nothing, and it matches the minimal update surfaces elsewhere -- a loader, +# one title, one static line. Failure swaps in a one-line error state that +# points at `hermes debug share`; the DETAIL travels through +# .hermes-update-result.json to the relaunched Desktop, never through this +# window. +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 { @@ -93,31 +101,48 @@ function Show-ProgressWindow { 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 Update" - $form.Size = New-Object System.Drawing.Size(720, 420) - $form.StartPosition = "CenterScreen" + $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.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) + $form.BackColor = $back + $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) + $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 restarts automatically when the update finishes." + $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($label) + $form.Controls.Add($title) + $form.Controls.Add($sub) $form.Show() # `cmd start /min` spawned us backgrounded; TopMost keeps the window # above others but does not take activation. Claim it explicitly so @@ -127,13 +152,47 @@ function Show-ProgressWindow { if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($form.Handle) | Out-Null } } catch {} [System.Windows.Forms.Application]::DoEvents() - $script:Ui = [pscustomobject]@{ Form = $form; Box = $box } + $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 { + # Terse by design: a title, the debug-share pointer, Close. No error + # text, no log tail -- `hermes debug share` uploads the real evidence and + # the relaunched Desktop surfaces the result message. + 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 Close-ProgressWindow { if ($script:Ui) { try { $script:Ui.Form.Close() } catch {} @@ -238,13 +297,13 @@ function Start-DesktopRelaunch { } } -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 +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 @@ -257,7 +316,7 @@ function Invoke-StreamedHermes([string]$Exe, [string[]]$HermesArgs, [string]$Tag $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. + # 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 @@ -266,44 +325,24 @@ function Invoke-StreamedHermes([string]$Exe, [string[]]$HermesArgs, [string]$Tag $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). + $outTask = $proc.StandardOutput.ReadToEndAsync() + $errTask = $proc.StandardError.ReadToEndAsync() 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 {} + $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 } - Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue - return @{ Code = $code; Output = $all } + return @{ Code = $proc.ExitCode; Output = $all } } $finalCode = 1 @@ -386,14 +425,14 @@ try { } $updateArgs = @("update", "--yes", "--gateway", "--force", "--branch", $Branch) Write-HandoffLog ("running: hermes " + ($updateArgs -join " ")) - $res = Invoke-StreamedHermes $hermesExe $updateArgs "update" + $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-StreamedHermes $hermesExe $updateArgs "update" + $res = Invoke-HermesStep $hermesExe $updateArgs "update" Write-HandoffLog "retry exit code: $($res.Code)" } @@ -405,7 +444,7 @@ try { $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" + $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 } } @@ -418,12 +457,13 @@ try { $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." + $finalMsg = "Update failed (exit $($res.Code)). Run `hermes debug share` in a terminal to send a report." } exit $finalCode } finally { Write-Result ($finalCode -eq 0) $finalCode $finalMsg Remove-MarkerIfOwned + if ($finalCode -ne 0) { Show-ErrorFinale } Close-ProgressWindow Start-DesktopRelaunch } From 503e61b3b1b03cf79aa94d79e95dbe88fd7ced96 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 22:40:13 -0500 Subject: [PATCH 02/11] feat(update): shim UI + event channel for the Windows hand-off scripts/desktop-update.ps1 moves to scripts/desktop-update/windows.ps1 (a compat forwarder stays at the old path for one asar/checkout skew cycle) and gains the shim: scripts/desktop-update/ui.html rendered in a chromeless Edge app window, fed done|error over a loopback /progress endpoint. The page is #75895's hand-off screen ported verbatim (Fourier Flow loader, one title, one line, OS light/dark, charcoal dark seeds); failure is the terse card pointing at hermes debug share. The WinForms card stays as the no-Edge fallback, same shape. Salvaged from the web-shell spike: TcpListener runspace server, Edge --app spawn with throwaway profile, degradation ladder, -SelfTestUi. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- scripts/desktop-update.ps1 | 478 +------------------- scripts/desktop-update/serve-ui.py | 48 +++ scripts/desktop-update/ui.html | 237 ++++++++++ scripts/desktop-update/windows.ps1 | 671 +++++++++++++++++++++++++++++ 4 files changed, 966 insertions(+), 468 deletions(-) create mode 100644 scripts/desktop-update/serve-ui.py create mode 100644 scripts/desktop-update/ui.html create mode 100644 scripts/desktop-update/windows.ps1 diff --git a/scripts/desktop-update.ps1 b/scripts/desktop-update.ps1 index 98024b1a0790f..227845ce011f9 100644 --- a/scripts/desktop-update.ps1 +++ b/scripts/desktop-update.ps1 @@ -1,469 +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 repo checkout (HERMES_HOME\hermes-agent) -# -Branch branch to update against -# -DesktopPid the Electron main process to wait out -# [-RelaunchExe ] 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 -} - -# The window is a veneer, not a participant: the update runs identically with -# or without it (any WinForms failure degrades to log-only), it streams -# nothing, and it matches the minimal update surfaces elsewhere -- a loader, -# one title, one static line. Failure swaps in a one-line error state that -# points at `hermes debug share`; the DETAIL travels through -# .hermes-update-result.json to the relaunched Desktop, never through this -# window. -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 } - 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.TopMost = $true - $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 restarts automatically when the update finishes." - $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; 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; Bar = $bar; Title = $title; Sub = $sub } - } catch { - # Headless session / WinForms unavailable: degrade to log-only. - $script:Ui = $null - } -} - -function Show-ErrorFinale { - # Terse by design: a title, the debug-share pointer, Close. No error - # text, no log tail -- `hermes debug share` uploads the real evidence and - # the relaunched Desktop surfaces the result message. - 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 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-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" -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 "\n\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 { - Write-Result ($finalCode -eq 0) $finalCode $finalMsg - Remove-MarkerIfOwned - if ($finalCode -ne 0) { Show-ErrorFinale } - 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 diff --git a/scripts/desktop-update/serve-ui.py b/scripts/desktop-update/serve-ui.py new file mode 100644 index 0000000000000..8d0a6bfe61221 --- /dev/null +++ b/scripts/desktop-update/serve-ui.py @@ -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() diff --git a/scripts/desktop-update/ui.html b/scripts/desktop-update/ui.html new file mode 100644 index 0000000000000..5f733ea0d2029 --- /dev/null +++ b/scripts/desktop-update/ui.html @@ -0,0 +1,237 @@ + + + + + +Hermes + + + +
+
+
+

Updating Hermes

+

Hermes will open once done.

+
+ + + diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 new file mode 100644 index 0000000000000..66e14f67ddf36 --- /dev/null +++ b/scripts/desktop-update/windows.ps1 @@ -0,0 +1,671 @@ +# 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 repo checkout (HERMES_HOME\hermes-agent) +# -Branch branch to update against +# -DesktopPid the Electron main process to wait out +# [-RelaunchExe ] 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 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) { + # 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-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 "\n\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 { + Write-Result ($finalCode -eq 0) $finalCode $finalMsg + Remove-MarkerIfOwned + if ($finalCode -ne 0) { Show-ErrorFinale $finalMsg } + Close-ProgressWindow + Start-DesktopRelaunch +} From c991e3f62fde4432adacc73da1ad71f9893ec2e1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 22:40:13 -0500 Subject: [PATCH 03/11] feat(update): posix hand-off orchestrator (mac/linux quit-first updates) scripts/desktop-update/posix.sh is the mac/linux twin of windows.ps1: the Desktop spawns it detached and QUITS; it waits the app out, runs plain hermes update (retry-once across the update boundary, truthful desktop-rebuild completion), swaps/relaunches the .app bundle (mac) or the release/*-unpacked binary when its sandbox helper is launchable (linux), writes .hermes-update-result.json, and drives the same shim. Repo-owned, so every update refreshes the code that drives the next one. resolvePosixScriptHandoff mirrors the Windows resolver (with the flat-path fallback covering the scripts/ reorg skew). --- apps/desktop/electron/updater-process.test.ts | 17 +- apps/desktop/electron/updater-process.ts | 51 +++- scripts/desktop-update/posix.sh | 228 ++++++++++++++++++ 3 files changed, 290 insertions(+), 6 deletions(-) create mode 100755 scripts/desktop-update/posix.sh diff --git a/apps/desktop/electron/updater-process.test.ts b/apps/desktop/electron/updater-process.test.ts index a694b28bc6b74..00e2e2b5d5255 100644 --- a/apps/desktop/electron/updater-process.test.ts +++ b/apps/desktop/electron/updater-process.test.ts @@ -170,7 +170,7 @@ test('resolveStagedUpdaterBinary returns null on Windows when nothing is staged' test('resolveUpdateScriptHandoff prefers the repo script on Windows when present', () => { const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent` - const expected = path.join(root, 'scripts', 'desktop-update.ps1') + const expected = path.join(root, 'scripts', 'desktop-update', 'windows.ps1') const handoff = resolveUpdateScriptHandoff(root, { isWindows: true, @@ -183,6 +183,19 @@ test('resolveUpdateScriptHandoff prefers the repo script on Windows when present assert.deepEqual(handoff.args, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', expected]) }) +test('resolveUpdateScriptHandoff falls back to the pre-reorg flat path', () => { + const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent` + const legacy = path.join(root, 'scripts', 'desktop-update.ps1') + + const handoff = resolveUpdateScriptHandoff(root, { + isWindows: true, + fileExists: candidate => candidate === legacy + }) + + assert.ok(handoff) + assert.equal(handoff.scriptPath, legacy) +}) + test('resolveUpdateScriptHandoff returns null when the checkout predates the script', () => { const handoff = resolveUpdateScriptHandoff(String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`, { isWindows: true, @@ -203,7 +216,7 @@ test('resolveUpdateScriptHandoff is Windows-only (POSIX updates in place)', () = test('wrapHandoffForDetachedConsole routes through cmd start with own console', () => { const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent` - const expected = path.join(root, 'scripts', 'desktop-update.ps1') + const expected = path.join(root, 'scripts', 'desktop-update', 'windows.ps1') const handoff = resolveUpdateScriptHandoff(root, { isWindows: true, diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index e5728361785a2..f147c1bcd408e 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -27,7 +27,7 @@ export interface UpdateScriptHandoff { * updater-side fix only reaches users when a new binary is built, signed and * published — which historically lags main by months and strands users on * long-fixed bugs (cache resolver #67369, marker self-adopt #74782; the - * 2026-08-09 incident chain). `scripts/desktop-update.ps1` lives in the repo + * 2026-08-09 incident chain). `scripts/desktop-update/windows.ps1` lives in the repo * checkout instead: every `hermes update` refreshes the code that drives the * NEXT update, and only PowerShell itself is frozen. * @@ -47,7 +47,50 @@ export function resolveUpdateScriptHandoff( return null } - const scriptPath = path.join(updateRoot, 'scripts', 'desktop-update.ps1') + const exists = deps.fileExists ?? stagedFileExists + + // Current layout first, then the pre-reorg flat path — an updated asar can + // meet a checkout from either side of the move (the checkout also ships a + // forwarder at the legacy path for the inverse skew). + for (const candidate of [ + path.join(updateRoot, 'scripts', 'desktop-update', 'windows.ps1'), + path.join(updateRoot, 'scripts', 'desktop-update.ps1') + ]) { + if (exists(candidate)) { + return { + command: 'powershell', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', candidate], + scriptPath: candidate + } + } + } + + return null +} + +/** + * Repo-owned POSIX update hand-off (the mac/linux twin of the above). + * + * Replaces the in-app posix updater: the Desktop spawns the script detached + * and QUITS, the script waits it out, runs `hermes update`, swaps/relaunches + * the app, and writes .hermes-update-result.json. With the app gone before + * the update starts, the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance is + * unnecessary — there are no live desktop backends to spare. + * + * Null when the checkout predates the script (caller surfaces the manual + * `hermes update` card — old checkouts pull the script on their next update). + */ +export function resolvePosixScriptHandoff( + updateRoot: string, + deps: ResolveUpdateScriptHandoffDeps = {} +): UpdateScriptHandoff | null { + const isWindows = deps.isWindows ?? process.platform === 'win32' + + if (isWindows) { + return null + } + + const scriptPath = path.join(updateRoot, 'scripts', 'desktop-update', 'posix.sh') const exists = deps.fileExists ?? stagedFileExists if (!exists(scriptPath)) { @@ -55,8 +98,8 @@ export function resolveUpdateScriptHandoff( } return { - command: 'powershell', - args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], + command: '/bin/bash', + args: [scriptPath], scriptPath } } diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh new file mode 100755 index 0000000000000..524803c003954 --- /dev/null +++ b/scripts/desktop-update/posix.sh @@ -0,0 +1,228 @@ +#!/bin/bash +# posix.sh -- repo-owned macOS/Linux Desktop update hand-off. +# +# The whole job: wait for the Desktop to exit, run `hermes update`, tell the +# shim how it went, reopen the app. The Desktop spawns this detached and +# quits; because it lives in the checkout, every update refreshes the code +# that drives the next one. Replaces the in-app updater +# (applyUpdatesPosixInApp) -- with the app gone before the update starts, +# the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance dies with it. +# +# CONTRACT (keep in sync with apps/desktop/electron/main.ts): +# bash scripts/desktop-update/posix.sh +# --install-root repo checkout (HERMES_HOME/hermes-agent) +# --branch branch to update against +# --desktop-pid the Electron main process to wait out +# [--relaunch-target

] mac: running .app to swap+reopen; +# linux: running binary (omit = no relaunch) +# [--no-ui] [--no-marker-cleanup] [--self-test-ui] +# +# The shim (ui.html in a chromeless browser app window) is decoration: it +# polls /progress for `done` or `error` and reacts. It owns nothing -- +# relaunch, result file, marker hygiene all happen here, identically, when +# no renderer exists. No chromium-family browser found = no UI, fine. + +set -u + +INSTALL_ROOT="" BRANCH="main" DESKTOP_PID=0 RELAUNCH_TARGET="" +NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0 +while [ $# -gt 0 ]; do + case "$1" in + --install-root) INSTALL_ROOT="$2"; shift 2 ;; + --branch) BRANCH="$2"; shift 2 ;; + --desktop-pid) DESKTOP_PID="$2"; shift 2 ;; + --relaunch-target) RELAUNCH_TARGET="$2"; shift 2 ;; + --no-ui) NO_UI=1; shift ;; + --no-marker-cleanup) NO_MARKER_CLEANUP=1; shift ;; + --self-test-ui) SELF_TEST_UI=1; shift ;; + *) echo "unknown arg: $1" >&2; exit 64 ;; + esac +done +[ "$SELF_TEST_UI" -eq 1 ] || [ -n "$INSTALL_ROOT" ] || { echo "--install-root is required" >&2; exit 64; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HERMES_HOME="${INSTALL_ROOT:+$(dirname "$INSTALL_ROOT")}" +HERMES_HOME="${HERMES_HOME:-${TMPDIR:-/tmp}}" +MARKER="$HERMES_HOME/.hermes-update-in-progress" +LOG_DIR="$HERMES_HOME/logs"; mkdir -p "$LOG_DIR" 2>/dev/null || true +LOG="$LOG_DIR/desktop-update-handoff.log" +RESULT="$HERMES_HOME/.hermes-update-result.json" +STATUS="${TMPDIR:-/tmp}/hermes-update-status.$$" + +UI_SERVER_PID="" UI_BROWSER_PID="" FINAL_CODE=1 +FINAL_MSG="update did not complete" + +log() { echo "$(date +%Y-%m-%dT%H:%M:%S%z) $1" | tee -a "$LOG" 2>/dev/null; } + +# ── shim ──────────────────────────────────────────────────────────────────── +publish() { # status message -- atomic replace; the server reads per poll + printf '{"status":"%s","message":"%s"}' "$1" "$2" > "$STATUS.tmp" && mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true + [ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state +} + +find_browser() { + local c + if [ "$(uname)" = "Darwin" ]; then + for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium" \ + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"; do + [ -x "$c" ] && { echo "$c"; return; } + done + else + for c in google-chrome google-chrome-stable chromium chromium-browser microsoft-edge brave-browser; do + command -v "$c" 2>/dev/null && return + done + fi +} + +start_ui() { + [ "$NO_UI" -eq 1 ] && return + local html="$SCRIPT_DIR/ui.html" py browser port="" i + py="${INSTALL_ROOT:+$INSTALL_ROOT/venv/bin/python3}" + [ -x "${py:-/nonexistent}" ] || py="$(command -v python3 2>/dev/null)" + browser="$(find_browser)" + { [ -f "$html" ] && [ -n "$py" ] && [ -n "$browser" ]; } || { log "shim: no renderer; skipping UI"; return; } + + publish "running" "" + "$py" "$SCRIPT_DIR/serve-ui.py" "$html" "$STATUS" > "$LOG_DIR/desktop-update-ui-port" 2>>"$LOG" & + UI_SERVER_PID=$! + for i in $(seq 1 10); do + port="$(tr -cd '0-9' < "$LOG_DIR/desktop-update-ui-port" 2>/dev/null)" + [ -n "$port" ] && break + sleep 0.2 + done + [ -n "$port" ] || { kill "$UI_SERVER_PID" 2>/dev/null; UI_SERVER_PID=""; return; } + + # Throwaway profile: new window/process we own; user's browser untouched. + "$browser" --app="http://127.0.0.1:$port/" --user-data-dir="${TMPDIR:-/tmp}/hermes-update-ui-$$" \ + --no-first-run --no-default-browser-check --window-size=280,320 >/dev/null 2>&1 & + UI_BROWSER_PID=$! + log "shim: app window on 127.0.0.1:$port" +} + +stop_ui() { # error state leaves the window up for the user to read + if [ -n "$UI_SERVER_PID" ]; then + { kill "$UI_SERVER_PID" && wait "$UI_SERVER_PID"; } 2>/dev/null + fi + if [ "${1:-}" != "leave-window" ] && [ -n "$UI_BROWSER_PID" ]; then + { kill "$UI_BROWSER_PID" && wait "$UI_BROWSER_PID"; } 2>/dev/null + fi + UI_SERVER_PID="" UI_BROWSER_PID="" +} + +# ── relaunch ──────────────────────────────────────────────────────────────── +relaunch() { + [ -n "$RELAUNCH_TARGET" ] || return 0 + if [ "$(uname)" = "Darwin" ]; then + # Swap the rebuilt bundle over the running one when both resolve, then + # `open` (fully detached). POSIX doesn't lock running executables. + local rebuilt="" c + for c in "$INSTALL_ROOT/apps/desktop/release/mac-arm64/Hermes.app" \ + "$INSTALL_ROOT/apps/desktop/release/mac/Hermes.app"; do + [ -d "$c" ] && { rebuilt="$c"; break; } + done + if [ -n "$rebuilt" ] && [ -d "$RELAUNCH_TARGET" ] && [ "$rebuilt" != "$RELAUNCH_TARGET" ]; then + if /usr/bin/ditto "$rebuilt" "$RELAUNCH_TARGET.new"; then + mv "$RELAUNCH_TARGET" "$RELAUNCH_TARGET.old" 2>/dev/null || rm -rf "$RELAUNCH_TARGET" + mv "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET" + rm -rf "$RELAUNCH_TARGET.old" 2>/dev/null || true + log "swapped app bundle" + else + rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true + log "WARNING: bundle copy failed; relaunching existing app" + fi + fi + /usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true + /usr/bin/open "$RELAUNCH_TARGET" || log "WARNING: relaunch failed" + else + # Linux: only relaunch a binary the rebuild actually replaced, with a + # launchable sandbox helper -- otherwise say so instead of lying (#37541). + case "$RELAUNCH_TARGET" in + */release/*-unpacked/*) + if [ -u "$(dirname "$RELAUNCH_TARGET")/chrome-sandbox" ] || [ -n "${HERMES_DESKTOP_NO_SANDBOX:-}" ]; then + (setsid "$RELAUNCH_TARGET" >/dev/null 2>&1 &) || log "WARNING: relaunch failed" + else + FINAL_MSG="Update complete. Reopen Hermes to finish (the app could not restart itself)." + fi ;; + *) + FINAL_MSG="Backend updated, but the desktop app package (AppImage/deb/rpm) was not changed. Update it to match." ;; + esac + fi +} + +finish() { + printf '{"ok":%s,"exit_code":%s,"message":"%s","branch":"%s","finished_at":%s}' \ + "$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" "$FINAL_MSG" "$BRANCH" "$(date +%s)" \ + > "$RESULT" 2>/dev/null || true + if [ "$NO_MARKER_CLEANUP" -eq 0 ] && [ "$(head -1 "$MARKER" 2>/dev/null | tr -d '[:space:]')" = "$$" ]; then + rm -f "$MARKER" 2>/dev/null || true + fi + if [ "$FINAL_CODE" -eq 0 ]; then publish "done" ""; stop_ui + else publish "error" "$FINAL_MSG"; stop_ui leave-window; fi + relaunch + rm -f "$STATUS" "$STATUS.tmp" "$LOG_DIR/desktop-update-ui-port" 2>/dev/null || true +} +trap finish EXIT + +# ── self-test: shim only, no update, touches nothing ─────────────────────── +if [ "$SELF_TEST_UI" -eq 1 ]; then + start_ui + log "SELF-TEST: shim simulation (no update will run)" + sleep "${HERMES_SELFTEST_HOLD_SECONDS:-6}" + RELAUNCH_TARGET="" + if [ -n "${HERMES_SELFTEST_FAIL:-}" ]; then FINAL_MSG="self-test error state" + else FINAL_CODE=0 FINAL_MSG="self-test complete"; fi + exit "$FINAL_CODE" +fi + +# ── the actual job ────────────────────────────────────────────────────────── +log "hand-off start: root=$INSTALL_ROOT branch=$BRANCH desktopPid=$DESKTOP_PID pid=$$" +rm -f "$RESULT" 2>/dev/null || true +start_ui + +# Marker claim: same cross-process lock contract as windows.ps1 / +# update_lock.py (the `hermes update` child adopts it via process ancestry). +printf '%s\n%s\n' "$$" "$(date +%s)" > "$MARKER" 2>/dev/null || log "WARNING: could not write update marker" + +# Wait out the Desktop (FAIL CLOSED: updating under live backends bricks). +if [ "$DESKTOP_PID" -gt 0 ] 2>/dev/null; then + for _ in $(seq 1 100); do kill -0 "$DESKTOP_PID" 2>/dev/null || break; sleep 0.3; done + if kill -0 "$DESKTOP_PID" 2>/dev/null; then + FINAL_CODE=4 FINAL_MSG="Update aborted: the Hermes window (pid $DESKTOP_PID) did not exit within 30s. Nothing was changed. Close Hermes fully and try again." + log "$FINAL_MSG"; exit "$FINAL_CODE" + fi +fi + +HERMES_BIN="$INSTALL_ROOT/venv/bin/hermes" +[ -x "$HERMES_BIN" ] || { FINAL_CODE=3 FINAL_MSG="Update aborted: $HERMES_BIN is missing. The install needs repair (run the Hermes installer or hermes doctor)."; log "$FINAL_MSG"; exit 3; } + +export PYTHONUNBUFFERED=1 +log "running: hermes update --yes --gateway --branch $BRANCH" +OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$? +printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null +log "hermes update exit code: $CODE" + +if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then + # Retry once: update-boundary class (fresh code on disk, stale in memory). + # Exit 2 ("close all Hermes windows") is not retryable. + log "retrying once (freshly pulled fix loads on the second run)" + OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$? + printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null + log "retry exit code: $CODE" +fi + +# Truthful completion: `hermes update` calls a GUI build failure non-fatal +# (exit 0). For a Desktop-driven update that would relaunch the OLD build +# and call it success -- retry the build once, propagate honestly. +if [ "$CODE" -eq 0 ] && printf '%s' "$OUT" | grep -q "Desktop build failed"; then + log "desktop build failed inside hermes update; retrying build" + "$HERMES_BIN" desktop --force-build --build-only >> "$LOG" 2>&1 || { + FINAL_CODE=6 FINAL_MSG="Code and dependencies updated, but the Desktop app rebuild failed - you are running the previous build. Run hermes desktop --force-build from a terminal to retry." + exit 6 + } +fi + +if [ "$CODE" -eq 0 ]; then FINAL_CODE=0 FINAL_MSG="Update complete." +else FINAL_CODE="$CODE" FINAL_MSG="Update failed (exit $CODE). Run hermes debug share in a terminal to send a report."; fi +exit "$FINAL_CODE" From c9f0b6824e49342930d9757a269739ee8a1a12e6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 22:40:13 -0500 Subject: [PATCH 04/11] refactor(desktop): replace the in-app posix updater with the hand-off applyUpdatesPosixInApp is gone: mac/linux Update now quits into the detached posix orchestrator, same shape as Windows. Deletes everything the in-app path dragged into main.ts -- runStreamedUpdate, the rebuild retry, the relaunch-outcome matrix (update-relaunch.ts/update-rebuild.ts and tests), shellQuote, resolveHermesCliBinary -- and with the app dead before the update starts, the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance (#37532) is structurally unnecessary on the desktop path. --- apps/desktop/electron/handoff-result.ts | 2 +- apps/desktop/electron/main.ts | 418 ++++-------------- apps/desktop/electron/update-rebuild.test.ts | 65 --- apps/desktop/electron/update-rebuild.ts | 29 -- apps/desktop/electron/update-relaunch.test.ts | 244 ---------- apps/desktop/electron/update-relaunch.ts | 314 ------------- 6 files changed, 82 insertions(+), 990 deletions(-) delete mode 100644 apps/desktop/electron/update-rebuild.test.ts delete mode 100644 apps/desktop/electron/update-rebuild.ts delete mode 100644 apps/desktop/electron/update-relaunch.test.ts delete mode 100644 apps/desktop/electron/update-relaunch.ts diff --git a/apps/desktop/electron/handoff-result.ts b/apps/desktop/electron/handoff-result.ts index c724be66e3118..1cfbfac55cdf1 100644 --- a/apps/desktop/electron/handoff-result.ts +++ b/apps/desktop/electron/handoff-result.ts @@ -1,7 +1,7 @@ /** * 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", diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 0a2c4a968584e..66db2716093b6 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -197,18 +197,9 @@ 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 { + resolvePosixScriptHandoff, resolveStagedUpdaterBinary, resolveUpdateScriptHandoff, spawnUpdaterProcess, @@ -1813,7 +1804,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 @@ -2868,14 +2859,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 +3015,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 +3213,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/Contents/MacOS/; climb three levels to the bundle root. function runningAppBundle() { @@ -3372,307 +3315,108 @@ 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 = { - 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 (script relaunches + // only when it actually replaced it — release/*-unpacked — and the + // sandbox helper is launchable; otherwise the result message says so). + 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 }) - - // 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 }) - } - - return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' }) + 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/-unpacked. - // We can only HONESTLY relaunch into the new GUI when the *running* binary IS - // that rebuilt one — i.e. execPath lives under release/-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')) diff --git a/apps/desktop/electron/update-rebuild.test.ts b/apps/desktop/electron/update-rebuild.test.ts deleted file mode 100644 index 6c2d75245500a..0000000000000 --- a/apps/desktop/electron/update-rebuild.test.ts +++ /dev/null @@ -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') -}) diff --git a/apps/desktop/electron/update-rebuild.ts b/apps/desktop/electron/update-rebuild.ts deleted file mode 100644 index a2a3581eccd8e..0000000000000 --- a/apps/desktop/electron/update-rebuild.ts +++ /dev/null @@ -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 } diff --git a/apps/desktop/electron/update-relaunch.test.ts b/apps/desktop/electron/update-relaunch.test.ts deleted file mode 100644 index 54e42eabf9b2a..0000000000000 --- a/apps/desktop/electron/update-relaunch.test.ts +++ /dev/null @@ -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/-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/-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/) -}) diff --git a/apps/desktop/electron/update-relaunch.ts b/apps/desktop/electron/update-relaunch.ts deleted file mode 100644 index 46ea789bbf696..0000000000000 --- a/apps/desktop/electron/update-relaunch.ts +++ /dev/null @@ -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/-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/-unpacked` dir. - * - * - Source / unpacked install (execPath under release/-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/-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/

-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 `/apps/desktop/release/-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 -} From ca5b015e720023da3bc42001833603b2ad2d238b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 22:40:13 -0500 Subject: [PATCH 05/11] test(update): sandboxed repro paths as npm scripts scripts/desktop-update/repro.sh drives the real code paths against a disposable HERMES_HOME under /tmp: shim/shim-fail (UI dry runs), fresh (literal install.sh), behind N (rewound checkout driven forward by the orchestrator), error (broken venv -> abort + result file). Exposed as npm run update:shim / update:shim:fail / update:repro:* from apps/desktop. --- apps/desktop/package.json | 5 ++ scripts/desktop-update/repro.sh | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100755 scripts/desktop-update/repro.sh diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7e21884540e3a..8fb9062ee3be2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -38,6 +38,11 @@ "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", "perf:serve": "node scripts/perf/serve.mjs", "test:desktop": "node scripts/test-desktop.mjs", "test:desktop:all": "node scripts/test-desktop.mjs all", diff --git a/scripts/desktop-update/repro.sh b/scripts/desktop-update/repro.sh new file mode 100755 index 0000000000000..088c5eee66c71 --- /dev/null +++ b/scripts/desktop-update/repro.sh @@ -0,0 +1,95 @@ +#!/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 +# +# 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" --no-interactive +} + +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 + ;; + *) + sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//' + exit 64 + ;; +esac From bdb4cfd35e31270635cbac330756a24207b1a777 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 11 Aug 2026 00:06:57 -0500 Subject: [PATCH 06/11] fix(update): posix hand-off truth ordering, relaunch-gate port, JSON escaping Address helix4u's review: - finish() now delivers the outcome BEFORE publishing it: mac bundle swap and the linux relaunch gate run first, then the result file, marker removal, and the shim event -- the app launch itself goes last so it can't race the result write. A gated/skewed linux install (AppImage/ deb/rpm, broken sandbox helper) surfaces its message in the result file AND holds the shim window open with it instead of closing on a false 'Opening Hermes...'. - mac swap is transactional with a checked rollback; a failed install restores the previous bundle and the result says so (exit 7 when even rollback fails). Failed 'open' rewrites the result truthfully. - linux gate is an exact port of the deleted update-relaunch.ts logic: anchored path-segment match on /apps/desktop/release/linux-unpacked, chrome-sandbox absent = namespace build = fine, present = root+setuid required, with the real opt-outs (ELECTRON_DISABLE_SANDBOX, --no-sandbox among replayed args, or the Desktop vouching) instead of the invented HERMES_DESKTOP_NO_SANDBOX. collectRelaunchArgs/sandboxFallbackFromEnv live in updater-process.ts again; the Desktop passes filtered launch args (after --) and --relaunch-cwd so a deep-link or --no-sandbox launch survives the update. - result/status JSON strings are escaped (git permits '"' in branch names) and the result write is atomic (tmp + rename). - coverage: resolvePosixScriptHandoff + ported helpers in updater-process.test.ts (19 pass); repro.sh gate / npm run update:repro:gate asserts the whole gate matrix and round-trips a hostile branch name through the result JSON. --- apps/desktop/electron/main.ts | 24 ++- apps/desktop/electron/updater-process.test.ts | 63 ++++++ apps/desktop/electron/updater-process.ts | 51 +++++ apps/desktop/package.json | 1 + scripts/desktop-update/posix.sh | 203 ++++++++++++++---- scripts/desktop-update/repro.sh | 43 ++++ scripts/desktop-update/ui.html | 5 +- 7 files changed, 343 insertions(+), 47 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 66db2716093b6..5fa1fac0cfa5d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -199,9 +199,11 @@ import { waitForUpdateClearance } from './update-gate' import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' import { + collectRelaunchArgs, resolvePosixScriptHandoff, resolveStagedUpdaterBinary, resolveUpdateScriptHandoff, + sandboxFallbackFromEnv, spawnUpdaterProcess, stagedUpdaterSupportsPrewrittenMarker, wrapHandoffForDetachedConsole @@ -3371,15 +3373,31 @@ async function applyUpdatesPosixHandoff(opts: any) { ] // Relaunch target: the running .app bundle on mac (script swaps the - // rebuilt bundle over it), the running binary elsewhere (script relaunches - // only when it actually replaced it — release/*-unpacked — and the - // sandbox helper is launchable; otherwise the result message says so). + // rebuilt bundle over it), the running binary elsewhere. The script's gate + // (an exact port of update-relaunch.ts's decideRelaunchOutcome) relaunches + // only a binary the rebuild replaced with a launchable sandbox helper — + // replaying the original launch context (filtered args, cwd, sandbox + // opt-out) so a deep-link or --no-sandbox launch survives the update. const targetApp = IS_MAC ? runningAppBundle() : process.execPath if (targetApp) { args.push('--relaunch-target', targetApp) } + const relaunchArgs = collectRelaunchArgs(process.argv.slice(1)) + + if (!IS_MAC) { + args.push('--relaunch-cwd', process.cwd()) + + if (sandboxFallbackFromEnv(process.env, relaunchArgs)) { + args.push('--sandbox-fallback') + } + + if (relaunchArgs.length) { + args.push('--', ...relaunchArgs) + } + } + const child = spawnUpdaterProcess(handoff.command, args, { cwd: HERMES_HOME, env: { diff --git a/apps/desktop/electron/updater-process.test.ts b/apps/desktop/electron/updater-process.test.ts index 00e2e2b5d5255..dbf3a765f7983 100644 --- a/apps/desktop/electron/updater-process.test.ts +++ b/apps/desktop/electron/updater-process.test.ts @@ -5,9 +5,12 @@ import path from 'node:path' import { test } from 'vitest' import { + collectRelaunchArgs, MARKER_SELF_ADOPT_EPOCH_MS, + resolvePosixScriptHandoff, resolveStagedUpdaterBinary, resolveUpdateScriptHandoff, + sandboxFallbackFromEnv, spawnUpdaterProcess, stagedUpdaterSupportsPrewrittenMarker, wrapHandoffForDetachedConsole @@ -246,3 +249,63 @@ test('wrapHandoffForDetachedConsole routes through cmd start with own console', 'main' ]) }) + +test('resolvePosixScriptHandoff returns the bash recipe when the script exists', () => { + const root = '/home/hermes/.hermes/hermes-agent' + const expected = path.join(root, 'scripts', 'desktop-update', 'posix.sh') + + const handoff = resolvePosixScriptHandoff(root, { + isWindows: false, + fileExists: candidate => candidate === expected + }) + + assert.ok(handoff) + assert.equal(handoff.command, '/bin/bash') + assert.deepEqual(handoff.args, [expected]) +}) + +test('resolvePosixScriptHandoff is null when the checkout predates the script', () => { + const handoff = resolvePosixScriptHandoff('/home/hermes/.hermes/hermes-agent', { + isWindows: false, + fileExists: () => false + }) + + assert.equal(handoff, null) +}) + +test('resolvePosixScriptHandoff is null on Windows', () => { + const handoff = resolvePosixScriptHandoff(String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`, { + isWindows: true, + fileExists: () => true + }) + + assert.equal(handoff, null) +}) + +test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', () => { + const argv = [ + '--type=renderer', + '--user-data-dir=/tmp/x', + '--enable-features=A,B', + '--field-trial-handle=123', + '--enable-logging', + '--log-file=/tmp/log', + '--lang=en-US', + '--inspect=9229', + '--remote-debugging-port=9222', + '--no-sandbox', + 'hermes://open/session/abc', + '--profile=work' + ] + + assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/session/abc', '--profile=work']) + assert.deepEqual(collectRelaunchArgs(undefined), []) +}) + +test('sandboxFallbackFromEnv: ELECTRON_DISABLE_SANDBOX / --no-sandbox opt out', () => { + assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: '1' }, []), true) + assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: 'true' }, []), true) + assert.equal(sandboxFallbackFromEnv({}, ['--no-sandbox']), true) + assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: '0' }, []), false) + assert.equal(sandboxFallbackFromEnv({}, []), false) +}) diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index f147c1bcd408e..ad6ae0d9f075b 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -137,6 +137,57 @@ export function wrapHandoffForDetachedConsole( } } +/** + * Electron/Chromium internal switches that must NOT be replayed on re-exec: + * runtime artifacts of THIS launch, not user intent (ported from the deleted + * update-relaunch.ts; #45205). `--no-sandbox` is deliberately kept — it is + * the user's sandbox opt-out and the signal that makes a relaunch safe when + * chrome-sandbox isn't setuid. + */ +export const INTERNAL_ARG_PREFIXES = [ + '--type=', + '--user-data-dir=', + '--enable-features=', + '--disable-features=', + '--field-trial-handle=', + '--enable-logging', + '--log-file=', + '--disable-gpu-sandbox', + '--lang=', + '--inspect', + '--remote-debugging-port=' +] + +/** Filter Electron internals from process.argv.slice(1) so the relaunched + * app replays only user/launcher intent (deep links, app flags). */ +export function collectRelaunchArgs(argv: unknown): string[] { + if (!Array.isArray(argv)) { + return [] + } + + return argv.filter((arg): arg is string => { + if (typeof arg !== 'string' || arg.length === 0) { + return false + } + + return !INTERNAL_ARG_PREFIXES.some(prefix => + prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=') + ) + }) +} + +/** True when the user has opted out of the SUID sandbox — the relaunch is + * safe even if chrome-sandbox fails preflight (ported from update-relaunch.ts). */ +export function sandboxFallbackFromEnv(env: Record, launchArgs: string[]): boolean { + const disable = String(env?.ELECTRON_DISABLE_SANDBOX || '').trim() + + if (disable === '1' || disable.toLowerCase() === 'true') { + return true + } + + return Array.isArray(launchArgs) && launchArgs.includes('--no-sandbox') +} + export interface ResolveStagedUpdaterBinaryDeps { isWindows?: boolean fileExists?: (candidate: string) => boolean diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8fb9062ee3be2..410fb65e2346b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -43,6 +43,7 @@ "update:repro:fresh": "bash ../../scripts/desktop-update/repro.sh fresh", "update:repro:behind": "bash ../../scripts/desktop-update/repro.sh behind", "update:repro:error": "bash ../../scripts/desktop-update/repro.sh error", + "update:repro:gate": "bash ../../scripts/desktop-update/repro.sh gate", "perf:serve": "node scripts/perf/serve.mjs", "test:desktop": "node scripts/test-desktop.mjs", "test:desktop:all": "node scripts/test-desktop.mjs all", diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index 524803c003954..a611626438c4d 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -15,26 +15,40 @@ # --desktop-pid the Electron main process to wait out # [--relaunch-target

] mac: running .app to swap+reopen; # linux: running binary (omit = no relaunch) -# [--no-ui] [--no-marker-cleanup] [--self-test-ui] +# [--relaunch-cwd

] linux: working directory to restore on relaunch +# [--sandbox-fallback] linux: the caller vouches for a sandbox opt-out +# (ELECTRON_DISABLE_SANDBOX / --no-sandbox launch) +# [--no-ui] [--no-marker-cleanup] [--self-test-ui] [--self-test-gate] +# [-- ] linux: filtered launch args to replay # # The shim (ui.html in a chromeless browser app window) is decoration: it # polls /progress for `done` or `error` and reacts. It owns nothing -- # relaunch, result file, marker hygiene all happen here, identically, when # no renderer exists. No chromium-family browser found = no UI, fine. +# +# ORDERING (the durable-truth rule): swap and relaunch are DECIDED AND +# EXECUTED before the result file is written, the marker is removed, or a +# terminal event reaches the shim. Nothing user-visible may claim an outcome +# the filesystem hasn't already delivered. set -u INSTALL_ROOT="" BRANCH="main" DESKTOP_PID=0 RELAUNCH_TARGET="" -NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0 +RELAUNCH_CWD="" SANDBOX_FALLBACK=0 RELAUNCH_ARGS=() +NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0 SELF_TEST_GATE=0 while [ $# -gt 0 ]; do case "$1" in --install-root) INSTALL_ROOT="$2"; shift 2 ;; --branch) BRANCH="$2"; shift 2 ;; --desktop-pid) DESKTOP_PID="$2"; shift 2 ;; --relaunch-target) RELAUNCH_TARGET="$2"; shift 2 ;; + --relaunch-cwd) RELAUNCH_CWD="$2"; shift 2 ;; + --sandbox-fallback) SANDBOX_FALLBACK=1; shift ;; --no-ui) NO_UI=1; shift ;; --no-marker-cleanup) NO_MARKER_CLEANUP=1; shift ;; --self-test-ui) SELF_TEST_UI=1; shift ;; + --self-test-gate) SELF_TEST_GATE=1; shift ;; + --) shift; RELAUNCH_ARGS=("$@"); shift $# ;; *) echo "unknown arg: $1" >&2; exit 64 ;; esac done @@ -51,12 +65,23 @@ STATUS="${TMPDIR:-/tmp}/hermes-update-status.$$" UI_SERVER_PID="" UI_BROWSER_PID="" FINAL_CODE=1 FINAL_MSG="update did not complete" +DONE_NOTE="" # set when the update succeeded but the app will NOT reopen itself log() { echo "$(date +%Y-%m-%dT%H:%M:%S%z) $1" | tee -a "$LOG" 2>/dev/null; } # ── shim ──────────────────────────────────────────────────────────────────── +json_escape() { # minimal JSON string escape: \ " and control whitespace + local s=${1//\\/\\\\} + s=${s//\"/\\\"} + s=${s//$'\n'/\\n} + s=${s//$'\r'/\\r} + s=${s//$'\t'/\\t} + printf '%s' "$s" +} + publish() { # status message -- atomic replace; the server reads per poll - printf '{"status":"%s","message":"%s"}' "$1" "$2" > "$STATUS.tmp" && mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true + printf '{"status":"%s","message":"%s"}' "$(json_escape "$1")" "$(json_escape "$2")" > "$STATUS.tmp" \ + && mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true [ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state } @@ -112,60 +137,152 @@ stop_ui() { # error state leaves the window up for the user to read } # ── relaunch ──────────────────────────────────────────────────────────────── -relaunch() { - [ -n "$RELAUNCH_TARGET" ] || return 0 - if [ "$(uname)" = "Darwin" ]; then - # Swap the rebuilt bundle over the running one when both resolve, then - # `open` (fully detached). POSIX doesn't lock running executables. - local rebuilt="" c - for c in "$INSTALL_ROOT/apps/desktop/release/mac-arm64/Hermes.app" \ - "$INSTALL_ROOT/apps/desktop/release/mac/Hermes.app"; do - [ -d "$c" ] && { rebuilt="$c"; break; } - done - if [ -n "$rebuilt" ] && [ -d "$RELAUNCH_TARGET" ] && [ "$rebuilt" != "$RELAUNCH_TARGET" ]; then - if /usr/bin/ditto "$rebuilt" "$RELAUNCH_TARGET.new"; then - mv "$RELAUNCH_TARGET" "$RELAUNCH_TARGET.old" 2>/dev/null || rm -rf "$RELAUNCH_TARGET" - mv "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET" - rm -rf "$RELAUNCH_TARGET.old" 2>/dev/null || true - log "swapped app bundle" - else +# Linux relaunch gate -- an exact port of the deleted update-relaunch.ts +# decision (#45205/#37541), not a loosened rewrite: +# * the running binary must live under THIS checkout's rebuilt +# apps/desktop/release/linux-unpacked (anchored, path-segment-aware -- +# proof the update we just ran replaced the selected executable); +# * chrome-sandbox ABSENT is fine (namespace-sandbox build; nothing to +# block on), PRESENT means root-owned AND setuid or Electron refuses to +# boot ("quit and never came back"); +# * a user sandbox opt-out (ELECTRON_DISABLE_SANDBOX=1/true in our +# inherited env, --no-sandbox among the replayed launch args, or the +# Desktop vouching via --sandbox-fallback) makes the relaunch safe +# despite a failed preflight. +# Outcomes mirror decideRelaunchOutcome: relaunch | skew | manual. +GATE="" GATE_MSG="" +linux_gate() { + local unpacked="$INSTALL_ROOT/apps/desktop/release/linux-unpacked" sb arg + case "$RELAUNCH_TARGET" in + "$unpacked"/*) ;; + *) GATE=skew GATE_MSG="Backend updated, but the desktop app package (AppImage/deb/rpm) was not changed. Update or reinstall it to match."; return ;; + esac + + sb="$unpacked/chrome-sandbox" + if [ ! -e "$sb" ]; then GATE=relaunch; return; fi + if [ -u "$sb" ] && [ "$(stat -c %u "$sb" 2>/dev/null)" = "0" ]; then GATE=relaunch; return; fi + + case "${ELECTRON_DISABLE_SANDBOX:-}" in 1|true|TRUE|True) GATE=relaunch; return ;; esac + [ "$SANDBOX_FALLBACK" -eq 1 ] && { GATE=relaunch; return; } + for arg in ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"}; do + [ "$arg" = "--no-sandbox" ] && { GATE=relaunch; return; } + done + + GATE=manual GATE_MSG="Update complete, but the rebuilt app can't relaunch itself (its sandbox helper needs root ownership). Reopen Hermes to finish." +} + +mac_swap() { + local rebuilt="" c + for c in "$INSTALL_ROOT/apps/desktop/release/mac-arm64/Hermes.app" \ + "$INSTALL_ROOT/apps/desktop/release/mac/Hermes.app"; do + [ -d "$c" ] && { rebuilt="$c"; break; } + done + + # Transactional swap: stage a full copy, move the old bundle aside, move + # the copy in. Every step checked; a failed final move ROLLS BACK so the + # user always has a launchable app, and the result file tells the truth. + if [ "$FINAL_CODE" -eq 0 ] && [ -n "$rebuilt" ] && [ -d "$RELAUNCH_TARGET" ] && [ "$rebuilt" != "$RELAUNCH_TARGET" ]; then + rm -rf "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET.old" 2>/dev/null || true + if ! /usr/bin/ditto "$rebuilt" "$RELAUNCH_TARGET.new"; then + rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true + DONE_NOTE="Update complete, but the new app could not be staged; the previous version was kept. Run the update again." + log "WARNING: bundle copy failed; keeping existing app" + elif ! mv "$RELAUNCH_TARGET" "$RELAUNCH_TARGET.old"; then + rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true + DONE_NOTE="Update complete, but the new app could not replace the old one; the previous version was kept. Run the update again." + log "WARNING: could not move old bundle aside; keeping existing app" + elif ! mv "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET"; then + if mv "$RELAUNCH_TARGET.old" "$RELAUNCH_TARGET"; then rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true - log "WARNING: bundle copy failed; relaunching existing app" + DONE_NOTE="Update complete, but the new app could not be installed; the previous version was restored. Run the update again." + log "WARNING: bundle install failed; rolled back to the previous app" + else + FINAL_CODE=7 FINAL_MSG="The update finished but installing the new app failed and the previous app could not be restored. Reinstall Hermes (the rebuilt app is at $rebuilt)." + log "ERROR: bundle install failed AND rollback failed" fi + else + rm -rf "$RELAUNCH_TARGET.old" 2>/dev/null || true + log "swapped app bundle" fi - /usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true - /usr/bin/open "$RELAUNCH_TARGET" || log "WARNING: relaunch failed" - else - # Linux: only relaunch a binary the rebuild actually replaced, with a - # launchable sandbox helper -- otherwise say so instead of lying (#37541). - case "$RELAUNCH_TARGET" in - */release/*-unpacked/*) - if [ -u "$(dirname "$RELAUNCH_TARGET")/chrome-sandbox" ] || [ -n "${HERMES_DESKTOP_NO_SANDBOX:-}" ]; then - (setsid "$RELAUNCH_TARGET" >/dev/null 2>&1 &) || log "WARNING: relaunch failed" - else - FINAL_MSG="Update complete. Reopen Hermes to finish (the app could not restart itself)." - fi ;; - *) - FINAL_MSG="Backend updated, but the desktop app package (AppImage/deb/rpm) was not changed. Update it to match." ;; - esac fi } -finish() { +deliver_outcome() { # the truth-determining half: swap bundles / gate the relaunch + [ -n "$RELAUNCH_TARGET" ] || return 0 + if [ "$(uname)" = "Darwin" ]; then + mac_swap + else + linux_gate + if [ "$GATE" != "relaunch" ] && [ "$FINAL_CODE" -eq 0 ]; then + DONE_NOTE="$GATE_MSG" + log "no relaunch ($GATE): $GATE_MSG" + fi + fi +} + +launch_app() { # runs LAST, after the result is durable (the relaunched + # Desktop consumes the result file on boot -- launching first races the + # write). Returns nonzero when a launch was due but did not happen. + [ -n "$RELAUNCH_TARGET" ] || return 0 + if [ "$(uname)" = "Darwin" ]; then + [ -d "$RELAUNCH_TARGET" ] || return 0 + /usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true + /usr/bin/open "$RELAUNCH_TARGET" || { log "WARNING: relaunch failed"; return 1; } + elif [ "$GATE" = "relaunch" ]; then + # Replay the original launch context: filtered args from the Desktop + # (after --), its cwd, and its env (inherited through our own spawn). + (cd "${RELAUNCH_CWD:-/}" 2>/dev/null || cd /; setsid "$RELAUNCH_TARGET" ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"} >/dev/null 2>&1 &) \ + || { log "WARNING: relaunch failed"; return 1; } + fi +} + +write_result() { printf '{"ok":%s,"exit_code":%s,"message":"%s","branch":"%s","finished_at":%s}' \ - "$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" "$FINAL_MSG" "$BRANCH" "$(date +%s)" \ - > "$RESULT" 2>/dev/null || true + "$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" \ + "$(json_escape "$FINAL_MSG")" "$(json_escape "$BRANCH")" "$(date +%s)" \ + > "$RESULT.tmp" 2>/dev/null && mv -f "$RESULT.tmp" "$RESULT" 2>/dev/null || true +} + +finish() { + # Ordering (helix4u's review): 1. deliver the outcome (swap/gate) so the + # truth exists; 2. durable result; 3. marker; 4. shim event; 5. relaunch. + deliver_outcome + [ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && FINAL_MSG="$DONE_NOTE" + write_result + if [ "$NO_MARKER_CLEANUP" -eq 0 ] && [ "$(head -1 "$MARKER" 2>/dev/null | tr -d '[:space:]')" = "$$" ]; then rm -f "$MARKER" 2>/dev/null || true fi - if [ "$FINAL_CODE" -eq 0 ]; then publish "done" ""; stop_ui - else publish "error" "$FINAL_MSG"; stop_ui leave-window; fi - relaunch + + if [ "$FINAL_CODE" -eq 0 ]; then + # A DONE_NOTE means the app will NOT reopen itself -- leave the window + # up showing the note instead of closing on a false "Opening Hermes…". + publish "done" "$DONE_NOTE" + if [ -n "$DONE_NOTE" ]; then stop_ui leave-window; else stop_ui; fi + else + publish "error" "$FINAL_MSG"; stop_ui leave-window + fi + + if ! launch_app && [ "$FINAL_CODE" -eq 0 ] && [ -z "$DONE_NOTE" ]; then + # Launch failed after "done" went out: nothing consumed the result yet + # (the app never started), so make it tell the truth for the next boot. + FINAL_MSG="Update complete. Reopen Hermes to finish (it could not restart itself)." + write_result + fi rm -f "$STATUS" "$STATUS.tmp" "$LOG_DIR/desktop-update-ui-port" 2>/dev/null || true } trap finish EXIT -# ── self-test: shim only, no update, touches nothing ─────────────────────── +# ── self-tests: no update, touch nothing ──────────────────────────────────── +if [ "$SELF_TEST_GATE" -eq 1 ]; then + # Prints the gate decision for the given --install-root/--relaunch-target + # and exits; scripts/desktop-update/repro.sh gate asserts the matrix. + trap - EXIT + linux_gate + echo "$GATE${GATE_MSG:+:$GATE_MSG}" + exit 0 +fi + if [ "$SELF_TEST_UI" -eq 1 ]; then start_ui log "SELF-TEST: shim simulation (no update will run)" diff --git a/scripts/desktop-update/repro.sh b/scripts/desktop-update/repro.sh index 088c5eee66c71..1560d98191a3e 100755 --- a/scripts/desktop-update/repro.sh +++ b/scripts/desktop-update/repro.sh @@ -14,6 +14,9 @@ # the "user who hasn't updated in a while" path # repro.sh error orchestrator against a broken install (missing # venv) -- exercises abort + result-file + shim error +# repro.sh gate linux relaunch-gate decision matrix (anchoring, +# sandbox preflight, opt-out fallbacks) -- asserts +# every outcome without touching a real install # # The sandbox persists between runs (~/tmp is fine to nuke): fresh reuses # nothing, behind/error reuse the last sandbox install when present because @@ -88,6 +91,46 @@ case "$MODE" in cat "$SANDBOX/.hermes-update-result.json" 2>/dev/null || echo "(none written)" echo ;; + gate) + # Pure-decision matrix for the linux relaunch gate. Builds a fake + # checkout layout under /tmp; --self-test-gate prints the decision and + # exits without running an update. + G="/tmp/hermes-gate-test.$$" + UNPACKED="$G/hermes-agent/apps/desktop/release/linux-unpacked" + mkdir -p "$UNPACKED" + touch "$UNPACKED/hermes" && chmod +x "$UNPACKED/hermes" + + fails=0 + expect() { # name expected actual + if [ "$2" = "$3" ]; then printf 'ok %s -> %s\n' "$1" "$3" + else printf 'FAIL %s -> %s (want %s)\n' "$1" "$3" "$2"; fails=$((fails+1)); fi + } + decide() { bash "$SCRIPT_DIR/posix.sh" --self-test-gate --install-root "$G/hermes-agent" "$@" | cut -d: -f1; } + + expect "appimage (not under unpacked)" skew "$(decide --relaunch-target /opt/Hermes/hermes)" + expect "sibling-prefix dir not fooled" skew "$(decide --relaunch-target "$UNPACKED-evil/hermes")" + expect "no chrome-sandbox (namespace)" relaunch "$(decide --relaunch-target "$UNPACKED/hermes")" + + touch "$UNPACKED/chrome-sandbox" + expect "sandbox not root/setuid" manual "$(decide --relaunch-target "$UNPACKED/hermes")" + expect "opt-out: --sandbox-fallback" relaunch "$(decide --relaunch-target "$UNPACKED/hermes" --sandbox-fallback)" + expect "opt-out: --no-sandbox launch arg" relaunch "$(decide --relaunch-target "$UNPACKED/hermes" -- --no-sandbox)" + expect "opt-out: ELECTRON_DISABLE_SANDBOX" relaunch "$(ELECTRON_DISABLE_SANDBOX=1 decide --relaunch-target "$UNPACKED/hermes")" + + # Result JSON must survive hostile strings (git allows `"` in branch + # names; messages carry arbitrary text) -- parse it back with python. + QHOME="$G/qhome"; mkdir -p "$QHOME/hermes-agent" + bash "$SCRIPT_DIR/posix.sh" --no-ui --no-marker-cleanup --desktop-pid 0 \ + --install-root "$QHOME/hermes-agent" --branch 'evil"branch\n$(x)' >/dev/null 2>&1 || true + if python3 -c "import json,sys; d=json.load(open('$QHOME/.hermes-update-result.json')); sys.exit(0 if d['branch']=='evil\"branch\\\\n\$(x)' and d['ok']==False else 1)"; then + printf 'ok result JSON escapes hostile branch/message\n' + else + printf 'FAIL result JSON escaping\n'; fails=$((fails+1)) + fi + + rm -rf "$G" + [ "$fails" -eq 0 ] && say "gate matrix: all pass" || { say "gate matrix: $fails FAILED"; exit 1; } + ;; *) sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//' exit 64 diff --git a/scripts/desktop-update/ui.html b/scripts/desktop-update/ui.html index 5f733ea0d2029..c021f57562c44 100644 --- a/scripts/desktop-update/ui.html +++ b/scripts/desktop-update/ui.html @@ -212,7 +212,10 @@ if (state.status === 'done') { settle('done') glyphEl.textContent = '\u2713' - lineEl.textContent = 'Opening Hermes\u2026' + // A done message means the update landed but Hermes will NOT reopen + // itself (package skew, sandbox helper) — say that, not a false + // "Opening Hermes…". The orchestrator leaves the window up for it. + lineEl.textContent = state.message || 'Opening Hermes\u2026' } else if (state.status === 'error') { settle('error') glyphEl.textContent = '\u2715' From f121cd8a065f4174adab69b8ccc056b0c41748b7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 11 Aug 2026 00:41:59 -0500 Subject: [PATCH 07/11] fix(update): run hermes update from the install root + unbreak fresh repro The posix orchestrator inherited the Desktop's cwd, and parts of the update pipeline resolve the tree they mutate from the working directory -- the sandboxed behind-repro caught it updating the DEVELOPER'S primary checkout (cwd at spawn time) while reporting success against the sandbox. cd "$INSTALL_ROOT" before running hermes update, matching the cwd:updateRoot contract of the deleted in-app path. Verified: rerun leaves the outside checkout untouched (reflog clean). repro.sh fresh used a --no-interactive flag install.sh doesn't have; non-TTY stdin (&1)"; CODE=$? diff --git a/scripts/desktop-update/repro.sh b/scripts/desktop-update/repro.sh index 1560d98191a3e..114888f1353d3 100755 --- a/scripts/desktop-update/repro.sh +++ b/scripts/desktop-update/repro.sh @@ -46,8 +46,10 @@ ensure_sandbox_install() { mkdir -p "$SANDBOX" # The literal user path: install.sh against a clone of THIS checkout, so # the repro reproduces what you're about to ship, not origin/main. + # Date: Tue, 11 Aug 2026 00:48:57 -0500 Subject: [PATCH 08/11] fix(update): launch acceptance before the terminal event, on both orchestrators gille's round-2 review: the terminal lifecycle claimed outcomes the launch hadn't delivered yet. - posix finish() reorders: outcome -> durable result+marker -> LAUNCH WITH ACCEPTANCE -> terminal event. mac acceptance is open's exit code (launchd rejects broken bundles loudly); linux verifies the setsid child is still alive 1.5s after spawn, so an instant exec failure downgrades to a held 'manual' state + truthful result instead of a vanished 'done'. Gated skew/manual outcomes publish a real 'manual' event (new third shim state -- still zero logic in the page). - Renderer-free linux recovery: when no chromium-family browser exists, manual/error outcomes fire notify-send/zenity/kdialog best-effort so a gated non-relaunch is never a silent disappearance. - windows.ps1 mirrors the contract: Start-DesktopRelaunch returns verified acceptance (WMI pid alive / fallback process alive; dying before the window appears counts as failure), and the finally block downgrades to Show-ManualFinale + rewritten result when the launch didn't land. Error path still relaunches after showing itself. - repro.sh launch / npm run update:repro:launch: real-orchestrator matrix for instant-exit relaunch downgrade and skew-message surfacing. - posix.sh cds into the install root before hermes update (found by the sandboxed behind-repro: parts of the update resolve the mutated tree from cwd, which is the Desktop's cwd -- it updated the DEVELOPER'S checkout while reporting success against the sandbox). --- apps/desktop/package.json | 1 + scripts/desktop-update/posix.sh | 81 +++++++++--- scripts/desktop-update/repro.sh | 48 +++++++ scripts/desktop-update/ui.html | 13 +- scripts/desktop-update/windows.ps1 | 194 ++++++++++++++++++++--------- 5 files changed, 252 insertions(+), 85 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 410fb65e2346b..704a41fb9fb90 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -44,6 +44,7 @@ "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", diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index c2e2169385358..2d01459aff1f9 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -83,6 +83,26 @@ publish() { # status message -- atomic replace; the server reads per poll printf '{"status":"%s","message":"%s"}' "$(json_escape "$1")" "$(json_escape "$2")" > "$STATUS.tmp" \ && mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true [ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state + # Renderer-free recovery surface (gille's review, round 2): on linux a + # gated skew/manual outcome or a failure may be the ONLY signal the user + # gets — the Desktop deliberately does not reopen, and without a + # chromium-family browser there is no shim window. libnotify/zenity ship + # with every desktop environment; best-effort, never fatal, mac excluded + # (the shim browser list is effectively always satisfiable there and + # `open` reopens the app even on error). + if [ -z "$UI_SERVER_PID" ] && [ "$(uname)" != "Darwin" ]; then + case "$1" in + manual|error) + if command -v notify-send >/dev/null 2>&1; then + notify-send -u critical "Hermes update" "$2" 2>/dev/null || true + elif command -v zenity >/dev/null 2>&1; then + (zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &) || true + elif command -v kdialog >/dev/null 2>&1; then + (kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &) || true + fi + ;; + esac + fi } find_browser() { @@ -220,19 +240,29 @@ deliver_outcome() { # the truth-determining half: swap bundles / gate the relaun fi } -launch_app() { # runs LAST, after the result is durable (the relaunched - # Desktop consumes the result file on boot -- launching first races the - # write). Returns nonzero when a launch was due but did not happen. +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 [ -d "$RELAUNCH_TARGET" ] || return 0 /usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true - /usr/bin/open "$RELAUNCH_TARGET" || { log "WARNING: relaunch failed"; return 1; } + # `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 - # Replay the original launch context: filtered args from the Desktop - # (after --), its cwd, and its env (inherited through our own spawn). - (cd "${RELAUNCH_CWD:-/}" 2>/dev/null || cd /; setsid "$RELAUNCH_TARGET" ${RELAUNCH_ARGS[@]+"${RELAUNCH_ARGS[@]}"} >/dev/null 2>&1 &) \ - || { log "WARNING: relaunch failed"; return 1; } + # 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 } @@ -244,8 +274,16 @@ write_result() { } finish() { - # Ordering (helix4u's review): 1. deliver the outcome (swap/gate) so the - # truth exists; 2. durable result; 3. marker; 4. shim event; 5. relaunch. + # 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" write_result @@ -254,20 +292,25 @@ finish() { rm -f "$MARKER" 2>/dev/null || true fi - if [ "$FINAL_CODE" -eq 0 ]; then - # A DONE_NOTE means the app will NOT reopen itself -- leave the window - # up showing the note instead of closing on a false "Opening Hermes…". - publish "done" "$DONE_NOTE" - if [ -n "$DONE_NOTE" ]; then stop_ui leave-window; else stop_ui; fi - else + 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 ! launch_app && [ "$FINAL_CODE" -eq 0 ] && [ -z "$DONE_NOTE" ]; then - # Launch failed after "done" went out: nothing consumed the result yet - # (the app never started), so make it tell the truth for the next boot. + if [ -n "$DONE_NOTE" ]; then + # Gated (skew/manual): no launch will happen by design. Say so and + # leave the window up — it is the only surface until the next boot. + publish "manual" "$DONE_NOTE"; 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)." 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 } diff --git a/scripts/desktop-update/repro.sh b/scripts/desktop-update/repro.sh index 114888f1353d3..0f55afb220aa1 100755 --- a/scripts/desktop-update/repro.sh +++ b/scripts/desktop-update/repro.sh @@ -133,6 +133,54 @@ case "$MODE" in 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 'Reopen Hermes' in d['message']" + else + # mac: `open` on a nonexistent bundle is the acceptance-failure analog + bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \ + --relaunch-target "$L/NoSuch.app" >/dev/null 2>&1 || true + expect_msg "missing bundle -> clean result (no launch due)" "d['ok']==True" + 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 '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 diff --git a/scripts/desktop-update/ui.html b/scripts/desktop-update/ui.html index c021f57562c44..0009a98e529ce 100644 --- a/scripts/desktop-update/ui.html +++ b/scripts/desktop-update/ui.html @@ -212,10 +212,15 @@ if (state.status === 'done') { settle('done') glyphEl.textContent = '\u2713' - // A done message means the update landed but Hermes will NOT reopen - // itself (package skew, sandbox helper) — say that, not a false - // "Opening Hermes…". The orchestrator leaves the window up for it. - lineEl.textContent = state.message || 'Opening Hermes\u2026' + 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' diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 index 66e14f67ddf36..682ca50fb07fb 100644 --- a/scripts/desktop-update/windows.ps1 +++ b/scripts/desktop-update/windows.ps1 @@ -358,6 +358,44 @@ function Show-ErrorFinale([string]$Message) { } 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 @@ -402,70 +440,83 @@ function Remove-MarkerIfOwned { } 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) { + # 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 { - # 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 + 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: desktop relaunch failed: $($_.Exception.Message)" + 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) { @@ -663,9 +714,28 @@ try { } 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 - Start-DesktopRelaunch + 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 + Show-ManualFinale $finalMsg + } + Close-ProgressWindow + } } From ba28a18b95e8cb2b1ae40641581e4a51692970c8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 11 Aug 2026 02:52:06 -0500 Subject: [PATCH 09/11] fix(update): fail-closed cd, rejected-launch semantics, guaranteed recovery surface gille's round 3: - cd into the install root FAILS CLOSED (set -u without set -e let a failed cd continue hermes update in the caller's tree -- the exact wrong-tree class the correction exists to kill). Honest result, exit 3. - A supplied mac relaunch target that is missing is a REJECTED launch -> manual downgrade; the launch matrix asserts the downgrade instead of codifying the old false success. A mac swap-failure DONE_NOTE now still relaunches the kept/rolled-back bundle before publishing manual. - notify_fallback: every rung falls through on EXECUTION failure (a notify-send that can't reach D-Bus no longer eats the message), mac gets osascript (present on every macOS -- Safari-only machines have no chromium shim), and the no-surface terminal case is an explicit logged contract: the result file carries the outcome to the next boot. - update:repro:fresh passes --non-interactive explicitly (prompt_yes_no falls back to /dev/tty, so /dev/null && return 0 + else + if command -v notify-send >/dev/null 2>&1; then + notify-send -u critical "Hermes update" "$2" 2>/dev/null && return 0 + fi + if command -v zenity >/dev/null 2>&1; then + (zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &) && return 0 + fi + if command -v kdialog >/dev/null 2>&1; then + (kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &) && return 0 + fi + fi + # Explicit contract for the no-surface case: the result file already + # tells the next boot the truth; log that this was the only channel. + log "NOTICE: no notification surface available; outcome reaches the user via the result file on next launch: $2" +} + publish() { # status message -- atomic replace; the server reads per poll printf '{"status":"%s","message":"%s"}' "$(json_escape "$1")" "$(json_escape "$2")" > "$STATUS.tmp" \ && mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true [ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state - # Renderer-free recovery surface (gille's review, round 2): on linux a - # gated skew/manual outcome or a failure may be the ONLY signal the user - # gets — the Desktop deliberately does not reopen, and without a - # chromium-family browser there is no shim window. libnotify/zenity ship - # with every desktop environment; best-effort, never fatal, mac excluded - # (the shim browser list is effectively always satisfiable there and - # `open` reopens the app even on error). - if [ -z "$UI_SERVER_PID" ] && [ "$(uname)" != "Darwin" ]; then - case "$1" in - manual|error) - if command -v notify-send >/dev/null 2>&1; then - notify-send -u critical "Hermes update" "$2" 2>/dev/null || true - elif command -v zenity >/dev/null 2>&1; then - (zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &) || true - elif command -v kdialog >/dev/null 2>&1; then - (kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &) || true - fi - ;; - esac - fi + [ -z "$UI_SERVER_PID" ] && notify_fallback "$1" "$2" } find_browser() { @@ -245,7 +251,9 @@ launch_app() { # attempted BEFORE the terminal event (launch acceptance is # was due but did not verifiably happen; caller downgrades to manual. [ -n "$RELAUNCH_TARGET" ] || return 0 if [ "$(uname)" = "Darwin" ]; then - [ -d "$RELAUNCH_TARGET" ] || return 0 + # A supplied target that no longer exists is a REJECTED launch (the + # swap failed badly or the bundle vanished) — not "no launch due". + [ -d "$RELAUNCH_TARGET" ] || { log "WARNING: relaunch target missing: $RELAUNCH_TARGET"; return 1; } /usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true # `open` talks to launchd and FAILS LOUDLY on a broken/unlaunchable # bundle — its exit code IS launch acceptance here. @@ -300,8 +308,12 @@ finish() { fi if [ -n "$DONE_NOTE" ]; then - # Gated (skew/manual): no launch will happen by design. Say so and - # leave the window up — it is the only surface until the next boot. + if [ "$(uname)" = "Darwin" ]; then + # mac DONE_NOTE = swap failed but the PREVIOUS bundle was kept/rolled + # back — bring it back up; the note still tells the user to re-run. + # A gated linux outcome (skew/manual) skips the launch BY DESIGN. + launch_app || true + fi publish "manual" "$DONE_NOTE"; stop_ui leave-window elif launch_app; then publish "done" ""; stop_ui @@ -360,8 +372,13 @@ HERMES_BIN="$INSTALL_ROOT/venv/bin/hermes" # Run FROM the install root: `hermes update` resolves the tree it mutates # from the working directory, and we inherit the Desktop's cwd (which can be # an unrelated repo — updating THAT instead of the install is the failure -# the sandbox repro caught). The in-app path always passed cwd:updateRoot. -cd "$INSTALL_ROOT" +# the sandbox repro caught). FAIL CLOSED: set -u without set -e means a +# failed cd would otherwise continue in the wrong tree — the exact class +# this correction exists to eliminate. +cd "$INSTALL_ROOT" || { + FINAL_CODE=3 FINAL_MSG="Update aborted: cannot enter the install root ($INSTALL_ROOT). Nothing was changed." + log "$FINAL_MSG"; exit 3 +} export PYTHONUNBUFFERED=1 log "running: hermes update --yes --gateway --branch $BRANCH" OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$? diff --git a/scripts/desktop-update/repro.sh b/scripts/desktop-update/repro.sh index 0f55afb220aa1..db7ff43b2396d 100755 --- a/scripts/desktop-update/repro.sh +++ b/scripts/desktop-update/repro.sh @@ -46,10 +46,8 @@ ensure_sandbox_install() { mkdir -p "$SANDBOX" # The literal user path: install.sh against a clone of THIS checkout, so # the repro reproduces what you're about to ship, not origin/main. - # /dev/null 2>&1 || true expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and 'Reopen Hermes' in d['message']" else - # mac: `open` on a nonexistent bundle is the acceptance-failure analog + # mac: a SUPPLIED target that is missing is a REJECTED launch and + # must downgrade to manual — never a clean "Update complete." bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \ --relaunch-target "$L/NoSuch.app" >/dev/null 2>&1 || true - expect_msg "missing bundle -> clean result (no launch due)" "d['ok']==True" + expect_msg "missing bundle downgrades to manual" "d['ok']==True and 'Reopen Hermes' in d['message']" fi # 2. gated skew: success result carries the skew message (the manual From 968ec6c6f4deb6069cfa5ee5f758acd25909afd2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 11 Aug 2026 09:27:01 -0500 Subject: [PATCH 10/11] fix(update): manual-result protocol so gated outcomes reach the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 of helix4u's review — the durable fallback is now real: - Result protocol gains `manual`: an ok result the user still must act on (reopen the app, reinstall the GUI package, fix the sandbox helper). Both orchestrators set it on every DONE_NOTE/downgrade path; the Desktop consumer surfaces manual results in a real dialog on next boot instead of a log line — the browserless-Linux disappearance now ends at a visible dialog, worst case one boot later. Older result files without the field parse as manual:false (covered). - notify ladder verifies EXECUTION, not existence: zenity/kdialog must survive their first second (an instant death means no display and falls through); the no-surface case is an explicit best-effort contract whose guaranteed channel is the result dialog. - mac DONE_NOTE + failed relaunch of the kept/rolled-back bundle is no longer swallowed (`|| true` dropped): the durable message carries both facts. - launch/gate matrices assert `manual` in the result JSON; consumer round-trip tested in handoff-result.test.ts. --- apps/desktop/electron/handoff-result.test.ts | 21 +++++++++ apps/desktop/electron/handoff-result.ts | 6 +++ apps/desktop/electron/main.ts | 13 +++++- scripts/desktop-update/posix.sh | 46 ++++++++++++++------ scripts/desktop-update/repro.sh | 6 +-- scripts/desktop-update/windows.ps1 | 9 ++-- 6 files changed, 80 insertions(+), 21 deletions(-) diff --git a/apps/desktop/electron/handoff-result.test.ts b/apps/desktop/electron/handoff-result.test.ts index 4d6f7b1cfe11a..1bc08f0ea84e1 100644 --- a/apps/desktop/electron/handoff-result.test.ts +++ b/apps/desktop/electron/handoff-result.test.ts @@ -67,3 +67,24 @@ 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') +}) diff --git a/apps/desktop/electron/handoff-result.ts b/apps/desktop/electron/handoff-result.ts index 1cfbfac55cdf1..8e473ea08694f 100644 --- a/apps/desktop/electron/handoff-result.ts +++ b/apps/desktop/electron/handoff-result.ts @@ -19,6 +19,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 } @@ -65,6 +70,7 @@ export function readAndConsumeHandoffResult( return { ok: Boolean(parsed?.ok), exitCode: Number.isFinite(Number(parsed?.exit_code)) ? Number(parsed.exit_code) : 1, + manual: Boolean(parsed?.manual), message: typeof parsed?.message === 'string' ? parsed.message : '', branch: typeof parsed?.branch === 'string' ? parsed.branch : '' } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 5fa1fac0cfa5d..e0a5cc25e3fc4 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1815,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}`) diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index 18d22c123e339..80d2715dd82df 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -80,11 +80,13 @@ json_escape() { # minimal JSON string escape: \ " and control whitespace } notify_fallback() { # status message — renderer-free recovery surface. - # Fires only when there is no shim window: a gated/failed outcome must - # never be a silent disappearance (gille rounds 2-3). Each rung FALLS - # THROUGH on execution failure (notify-send existing but unable to reach - # D-Bus must not eat the message), ending at osascript on mac — present - # on every macOS — and at a logged last resort everywhere. + # 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 @@ -92,16 +94,23 @@ notify_fallback() { # status message — renderer-free recovery surface. 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 &) && return 0 + 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 &) && return 0 + 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 - # Explicit contract for the no-surface case: the result file already - # tells the next boot the truth; log that this was the only channel. - log "NOTICE: no notification surface available; outcome reaches the user via the result file on next launch: $2" + # 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 @@ -274,9 +283,12 @@ launch_app() { # attempted BEFORE the terminal event (launch acceptance is fi } +MANUAL=0 # 1 = update landed but the user must act (result protocol field) + write_result() { - printf '{"ok":%s,"exit_code":%s,"message":"%s","branch":"%s","finished_at":%s}' \ + 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 } @@ -293,7 +305,7 @@ finish() { # 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" + [ "$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 @@ -312,15 +324,21 @@ finish() { # mac DONE_NOTE = swap failed but the PREVIOUS bundle was kept/rolled # back — bring it back up; the note still tells the user to re-run. # A gated linux outcome (skew/manual) skips the launch BY DESIGN. - launch_app || true + 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" "$DONE_NOTE"; stop_ui leave-window + 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 diff --git a/scripts/desktop-update/repro.sh b/scripts/desktop-update/repro.sh index db7ff43b2396d..f2d39a3bf8d09 100755 --- a/scripts/desktop-update/repro.sh +++ b/scripts/desktop-update/repro.sh @@ -159,13 +159,13 @@ case "$MODE" in 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 'Reopen Hermes' in d['message']" + 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 'Reopen Hermes' in d['message']" + 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 @@ -174,7 +174,7 @@ case "$MODE" in 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 'was not changed' in d['message']" + 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" diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 index 682ca50fb07fb..1c81bdf1146b4 100644 --- a/scripts/desktop-update/windows.ps1 +++ b/scripts/desktop-update/windows.ps1 @@ -409,13 +409,16 @@ function Close-ProgressWindow { } } -function Write-Result([bool]$Ok, [int]$Code, [string]$Message) { +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. + # 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) @@ -733,7 +736,7 @@ try { # 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 + Write-Result $true 0 $finalMsg $true Show-ManualFinale $finalMsg } Close-ProgressWindow From 4280413dba456b7120b428f2f1465ae4e79c060b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 11 Aug 2026 17:03:00 -0500 Subject: [PATCH 11/11] fix(update): exempt manual results from the hand-off freshness window A manual:true hand-off result is 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. The 30-minute freshness gate discarded it if the user reopened Hermes later, stranding exactly the machine the channel exists to serve. Parse before the age check and skip the window for manual results; the file is still unlinked before any age check, so it's surfaced at most once. Ordinary results still expire. Regression: a stale ordinary result is discarded (and consumed) while a stale manual result is still returned once. --- apps/desktop/electron/handoff-result.test.ts | 25 +++++++++++++++++++ apps/desktop/electron/handoff-result.ts | 26 ++++++++++++++++---- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/desktop/electron/handoff-result.test.ts b/apps/desktop/electron/handoff-result.test.ts index 1bc08f0ea84e1..6d74f7f64965f 100644 --- a/apps/desktop/electron/handoff-result.test.ts +++ b/apps/desktop/electron/handoff-result.test.ts @@ -88,3 +88,28 @@ test('manual flag survives the round trip and defaults false', () => { 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') +}) diff --git a/apps/desktop/electron/handoff-result.ts b/apps/desktop/electron/handoff-result.ts index 8e473ea08694f..ada072b5178db 100644 --- a/apps/desktop/electron/handoff-result.ts +++ b/apps/desktop/electron/handoff-result.ts @@ -6,9 +6,17 @@ * 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' @@ -61,16 +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: Boolean(parsed?.manual), + manual, message: typeof parsed?.message === 'string' ? parsed.message : '', branch: typeof parsed?.branch === 'string' ? parsed.branch : '' }