Files
hermes-agent/tests/install/windows-e2e.ps1
ethernet c68f80664e test(install-e2e): the windows source update check reads staged main, not GitHub main
The Desktop update check asks api.github.com for main's tip before it asks
Git. The windows GUI update driver only redirected Git transport, and the
app's probe Git runs with GIT_CONFIG_GLOBAL=/dev/null, so it never saw the
redirect. When real GitHub main equalled the installed OLD commit, the app
reported no update and the leg waited 3 minutes for an Update now button
that could not appear.

Install the staged-main probe the macOS and Linux source legs already use.
It wraps Git with the staged insteadOf rewrite and selects --branch main,
so the real checker, UI and updater still run against serve.git.
2026-10-07 22:51:04 -07:00

1747 lines
91 KiB
PowerShell

# ============================================================================
# Windows Desktop GUI install + update E2E driver (the REAL user flow)
# ============================================================================
# Proves, on a real Windows machine, that a user who installs Hermes the way
# the website tells them to can then update to the commit under test through
# a real update surface -- with every leg driven through the GUI a user
# actually touches:
#
# INSTALL - downloads the production Hermes-Setup.exe from the website,
# launches it HEADED, and AutoHotkey clicks Install, waits,
# then clicks Launch. The real Electron Hermes.exe must appear.
# The exe runs EXACTLY as shipped against serve.git, whose
# `main` is parked at OLD (-InstallRef, default: the newest
# release tag) -- so the install lands on OLD the same way a
# user's install landed on whatever main served that day.
# UPDATE - OLD -> HEAD through the route selected by -Route:
# desktop (implemented) launch the installed Hermes.exe
# under Playwright's Electron driver and CLICK
# Settings -> About -> "Update now". The
# production hand-off chain runs untouched:
# marker, app quit, detached updater, `hermes
# update`, desktop rebuild, RELAUNCH. Asserts
# target sha, marker cleanup, result JSON (when
# the script path wrote one), working hermes,
# and the relaunched app window.
# update run `hermes update` from the installed command
# (the CLI route a GUI user might take).
# installer re-run the bootstrap installer over the
# existing install (download Hermes-Setup.exe
# again, AHK clicks Install; lands on HEAD).
#
# HOW THE STAGING WORKS (no MITM proxy, no network fakery):
# We bare-clone the checkout into <workroot>\serve.git and point every git
# process at it with url.<file-url>.insteadOf rewrites for the two
# canonical repo URLs, via a driver-owned gitconfig selected with
# GIT_CONFIG_GLOBAL. (NOT GIT_CONFIG_COUNT/KEY_n/VALUE_n env config --
# install.ps1 sets those itself and silently clobbers them.) The
# installer's `git clone` and `hermes update`'s `git fetch origin`
# transparently hit OUR bare repo. Its `main` serves OLD for the install
# phase; the update phase advances it to HEAD -- an update becomes
# available exactly the way it does for a real user. Installer and
# updater run byte-for-byte as shipped; everything else (uv, PyPI, npm,
# the installer's raw.githubusercontent install.ps1 download) uses the
# real network, same as a user install.
#
# PROOF: screenshots at every renderer step (Playwright), full-desktop
# screenshots around the installer/AHK phases, a rolling desktop capture
# (every 3s) plus a continuous ffmpeg screen recording (recording.mkv) for
# both phases, ahk.log, and the hand-off log. All uploaded as CI artifacts.
#
# DEVIATIONS FROM PRODUCTION (each one deliberate and small):
# * the git URL redirect itself
# * serve.git gets uploadpack.allowAnySHA1InWant=true so the installer's
# baked -Commit pin can be fetched from the redirected clone the same
# way GitHub's upload-pack allows it.
# * A dummy provider key is seeded after install so the update leg sees
# the ready app shell instead of the onboarding overlay (a real
# updating user has a configured provider).
# * The git shim reports the official URL. Detached updaters can resolve a
# different git.exe, so the test home also records that upstream setup was
# declined. The file:// transport must not prompt to add a second remote.
#
# USAGE (local Windows box or CI):
# powershell -File tests\install\windows-e2e.ps1 -Phase all
# ... -Phase stage / install / update / verify-stamp
# Phases share state via <workroot>\shas.json, so CI can run them as
# separate steps for readable logs. -InstallMethod and -Route are
# orthogonal axes: the install phase dispatches on -InstallMethod, the
# update phase on -Route, and install writes what update needs (paths,
# how OLD landed) into the shared state - so any implemented update can
# follow any implemented install.
# ============================================================================
param(
[ValidateSet("stage", "install", "update", "verify-stamp", "all")]
[string]$Phase = "all",
# How OLD gets installed, named by the same ids the combination
# generator (scripts/sandbox/generate-e2e-matrix.mjs) declares.
[ValidateSet("desktop-installer@latest", "installer-script", "installer-script+desktop")]
[string]$InstallMethod = "desktop-installer@latest",
# Update method to exercise in the update phase, same id namespace.
# open-app-update (from a desktop-installer install) and hermes-update /
# installer-script / installer-script+desktop (from script installs) are
# implemented; the rest are declared arms so the surface is stable when
# they land.
[ValidateSet("open-app-update", "hermes-desktop-app-update", "hermes-update", "desktop-installer@latest", "installer-script", "installer-script+desktop")]
[string]$Route = "open-app-update",
# The OLD version: the ref served as `main` while the installer runs,
# i.e. what the user starts on. The published Hermes-Setup.exe carries
# no commit pin (Pin { commit: None, branch: "main" }) -- it installs
# whatever `main` points at, so staging OLD means serving it there.
# Empty or "auto" = newest release tag in the checkout (the "user on
# the current release" starting point, same philosophy as the linux
# axis's tag matrix). "auto" exists because `powershell -File` silently
# swallows an empty-string argument ('Missing an argument for
# parameter'), so the workflow cannot pass "".
[string]$InstallRef = "auto",
# Update target ref (default HEAD). A stable-to-stable leg passes the
# next release tag here; only label the leg stable-to-stable when BOTH
# refs are release tags. NEXT mints a synthetic child of -InstallRef
# (the HEAD -> NEXT leg: install HEAD, update with HEAD's own updater).
[string]$UpdateRef = "HEAD",
# Repo checkout whose HEAD is the update target.
[string]$RepoRoot = "",
[string]$WorkRoot = $(if ($env:HERMES_E2E_WORKROOT) { $env:HERMES_E2E_WORKROOT } else { Join-Path $env:TEMP "hermes-desktop-gui-e2e" }),
[string]$SetupExeUrl = "https://hermes-assets.nousresearch.com/Hermes-Setup.exe",
# Driver dependencies come from the current checkout lockfile.
[string]$DriverNode = $env:HERMES_E2E_NODE
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
# Match an interactive Unicode console when Python output is piped into the
# UTF-8 transcript. Old releases otherwise select cp1252 and crash on banners.
$env:PYTHONIOENCODING = "utf-8"
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
$OutputEncoding = [Console]::OutputEncoding
# One PM tool store per leg. setup-pm exports HERMES_RUNTIME_DIR (its own
# store) and HERMES_PYTHON for this job's tooling. A user's machine has one
# store, <HERMES_HOME>\tools, and the desktop smoke already launches the app
# that way (smoke-env.mjs drops every HERMES_* override). When install.ps1
# and `hermes update` saw the job's store while the smoke did not, a leg
# settled onto two stores and the update rewrote its own running launcher
# ("source launcher publication failed"). No product step (installer, app,
# update, chat) sees the job's variables; the driver keeps setup-pm only
# through $DriverPython. PATH keeps setup-pm's tools, as older installers
# expect uv/ripgrep there.
$DriverPython = if ($env:HERMES_PYTHON) { $env:HERMES_PYTHON } else { (Get-Command python.exe -ErrorAction Stop).Source }
foreach ($jobOnly in @('HERMES_RUNTIME_DIR', 'HERMES_PYTHON', 'VIRTUAL_ENV')) {
if (Test-Path -LiteralPath "env:$jobOnly") { Remove-Item -LiteralPath "env:$jobOnly" }
}
if (-not $RepoRoot) {
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
}
# The workflow's HERMES_E2E_WORKROOT is "<workspace>\..\hermes-desktop-gui-e2e".
# Everything below derives HERMES_HOME (and so the PM store) from it, and a
# user's HERMES_HOME has no ".." segment. With one, Windows reports the store
# Python's sys.executable normalized while PM names it with the "..", and
# prepare_launch() relaunched into the same interpreter forever (#122513).
$WorkRoot = [System.IO.Path]::GetFullPath($WorkRoot)
$ServeRepo = Join-Path $WorkRoot "serve.git"
$HermesHome = Join-Path $WorkRoot "hermes-home"
$InstallDir = Join-Path $HermesHome "hermes-agent"
$StatePath = Join-Path $WorkRoot "shas.json"
$ProofRoot = Join-Path $WorkRoot "proof"
$AhkDir = Join-Path $WorkRoot "ahk"
$AssetsDir = Join-Path $PSScriptRoot "e2e-assets"
if (-not $DriverNode) { $DriverNode = (Get-Command node.exe -ErrorAction Stop).Source }
$env:HERMES_E2E_NODE = $DriverNode
$env:HERMES_DESKTOP_USER_DATA_DIR = Join-Path $WorkRoot 'electron-user-data'
$script:ChatMock = $null
$script:ChatFailure = $false
. (Join-Path $AssetsDir 'desktop-smoke-windows.ps1')
function Start-JourneyChat {
if (-not $script:ChatMock) {
$script:ChatFailure = $true
$script:ChatMock = Start-DesktopJourneyMock $DriverNode $AssetsDir $WorkRoot $HermesHome $ProofRoot
$script:ChatFailure = $false
}
}
function Invoke-DesktopCheckpoint([string]$ChatPhase, [string]$Commit, [string]$Method) {
$script:ChatFailure = $true
$prevEap = $ErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
& $DriverNode (Join-Path $AssetsDir 'source-desktop-smoke.mjs') `
--root $InstallDir --home $HermesHome --user-data $env:HERMES_DESKTOP_USER_DATA_DIR `
--out $ProofRoot --phase $ChatPhase --expect-commit $Commit --desktop $script:ExpectedDesktop --method $Method
$chatExit = $LASTEXITCODE
} finally { $ErrorActionPreference = $prevEap }
if ($chatExit -ne 0) { throw "Mandatory desktop chat $ChatPhase failed (exit $chatExit)" }
$script:ChatFailure = $false
}
function Confirm-OldChat([string]$Out) {
$receipt = Get-Content -LiteralPath (Join-Path $Out 'desktop-chat-old.json') -Raw | ConvertFrom-Json
if ($receipt.status -ne 'passed') { throw 'Mandatory OLD update-window chat failed' }
$script:ChatFailure = $false
}
$RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git"
$RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git"
function Write-Step([string]$Message) {
Write-Host ""
Write-Host ("=" * 74)
Write-Host "== $Message"
Write-Host ("=" * 74)
}
function Assert-True([bool]$Condition, [string]$Message) {
if (-not $Condition) {
throw "E2E ASSERTION FAILED: $Message"
}
Write-Host " [ok] $Message"
}
function Invoke-Git([string[]]$GitArgs) {
# PS 5.1 trap: under $ErrorActionPreference = "Stop", a native command
# that writes ANYTHING to stderr while merged via 2>&1 throws a
# NativeCommandError even when it exits 0 (git loves stderr for
# progress/notices). Relax EAP around the native call only; exit-code
# checking below is the real error gate.
#
# ALWAYS the real git.exe, never the shim we ship.
# annoying bug where .bat files eat ^ args.
# if hermes ever adds a git command that calls something with ^ this will break, lol.
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $script:RealGitExe @GitArgs 2>&1
if ($LASTEXITCODE -ne 0) {
throw "git $($GitArgs -join ' ') failed (exit $LASTEXITCODE): $output"
}
return ($output | Out-String).Trim()
} finally {
$ErrorActionPreference = $prevEap
}
}
function Set-GitRedirect {
# we redirect to our own repo so we can play around with what commit hermes thinks we're on.
# MECHANISM: a driver-owned global gitconfig selected via
# GIT_CONFIG_GLOBAL. Do NOT use GIT_CONFIG_COUNT/KEY_n/VALUE_n env
# config here -- install.ps1 SETS those itself (GIT_CONFIG_COUNT=1,
# windows.appendAtomically), silently clobbering any redirect we put
# there. install.ps1's own `git config --global` writes simply land in
# our file, so its compat settings still apply. Nothing leaks onto the
# machine: the file lives in the workroot and dies with it.
$fileUrl = "file:///" + ($ServeRepo -replace "\\", "/")
$gitCfg = Join-Path $WorkRoot "e2e-gitconfig"
if (-not (Test-Path -LiteralPath $WorkRoot)) {
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
}
# first, get the set origin url
$actualGitUrl = Invoke-Git @("-C", $RepoRoot, "remote", "get-url", "origin")
# then override it
@"
[url "$fileUrl"]
insteadOf = $actualGitUrl
insteadOf = $RepoUrlHttps
insteadOf = $RepoUrlSsh
"@ | Set-Content -LiteralPath $gitCfg -Encoding ASCII
$env:GIT_CONFIG_GLOBAL = $gitCfg
# check it worked
$actualGitUrl = Invoke-Git @("-C", $RepoRoot, "remote", "get-url", "origin")
Assert-True ($actualGitUrl -eq $fileUrl) "git URL redirect: origin resolves to '$actualGitUrl', expected '$fileUrl'."
Write-Host " git URL redirect via GIT_CONFIG_GLOBAL=$gitCfg"
Write-Host " $RepoUrlHttps -> $fileUrl"
# shim git and make 'git remote get-url origin' report the actual HA upstream
# insteadOf is transparent for transport but `git remote get-url origin` gives you the
# replacement, so _get_origin_url() sees file://$SERVE_REPO and _is_fork() would return true.
# we check for the arguments "remote get-url origin" in order in any position
# to allow for e.g. -c with some config being passed.
# if we didn't do this, we'd need the .skip_upstream_prompt file to prevent a hang in headless,"add the
# official repo as upstream?" prompt would hang a headless run. But we don't anymore :D
# The dispatch-time capture, not PATH: a fresh-machine leg has already
# stripped git from PATH by the time stage re-arms the redirect.
$realGit = $script:RealGitExe
# Export the real git so later checks can observe the TRANSPORT url. Once
# the shim below is on PATH, `git` reports the official origin for
# `remote get-url origin` (so fork detection sees it); any check that must
# see the file:// redirect instead has to bypass the shim via this path.
$env:HERMES_E2E_REAL_GIT = $realGit
if ($script:FreshMachine) {
# A fresh Windows box has no git. install.ps1's Get-PinnedGit returns
# ANY git on PATH (the dev shortcut), so the runner's git -- or the
# shim below -- would skip pinned-git staging entirely. Take every
# git.exe directory off PATH and install no shim: the product must
# provision its own. The shim's one job (fork detection seeing the
# official origin) is covered by .skip_upstream_prompt, same as
# routes whose detached updater bypasses the shim.
$kept = @($env:PATH -split ';' | Where-Object { $_ -and -not (Test-Path -LiteralPath (Join-Path $_ 'git.exe')) })
$env:PATH = $kept -join ';'
Assert-True (-not (Get-Command git -ErrorAction SilentlyContinue)) "fresh machine: no git resolvable on PATH"
Write-Host " fresh machine: git removed from PATH, no remote get-url shim"
return
}
$shimDir = Join-Path $WorkRoot "shim"
New-Item -ItemType Directory -Path $shimDir -Force | Out-Null
$shimPath = Join-Path $shimDir "git.bat"
@"
@echo off
setlocal enabledelayedexpansion
set prev2=
set prev1=
:loop
if "%~1"=="" goto passthrough
if /I "!prev2!"=="remote" if /I "!prev1!"=="get-url" if /I "%~1"=="origin" (
echo $RepoUrlHttps
exit /b 0
)
set prev2=!prev1!
set prev1=%~1
shift
goto loop
:passthrough
`"$realGit`" %*
exit /b %ERRORLEVEL%
"@ | Set-Content -LiteralPath $shimPath -Encoding ASCII
$env:PATH = "$shimDir;$env:PATH"
# Check it worked THROUGH the shim - deliberately not Invoke-Git, which
# pins the real git.exe. `git` via PATH here is exactly how the
# product's callers resolve it. Probe the intercepted verb AND plain
# passthrough.
#
# KNOWN HOLE, accepted: cmd parses the command line before the bat sees
# %*, and callers only quote args containing whitespace (PowerShell
# native binding and python's list2cmdline alike) - so a caret arg like
# rev-parse HEAD^{commit} loses its caret THROUGH ANY .bat, unfixably.
# A .ps1 shim would dodge cmd but PATHEXT-resolving callers (python -
# the shim's entire audience) never see .ps1 files, so .bat it stays.
# The driver's own git plumbing therefore pins git.exe (Invoke-Git),
# and the product's shimmed flows (fork detection: remote get-url) use
# no caret revs. If a product path ever sends carets through the shim,
# the leg fails loudly on a bad-revision error naming the mangled arg.
$prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
$observedGitUrl = (& git -C $RepoRoot remote get-url origin 2>&1 | Out-String).Trim()
$passthroughProbe = (& git -C $RepoRoot rev-parse HEAD 2>&1 | Out-String).Trim()
$passthroughExit = $LASTEXITCODE
$ErrorActionPreference = $prevEap
Assert-True ($observedGitUrl -eq $RepoUrlHttps) "git remote get-url shim: origin resolves to '$observedGitUrl', expected '$RepoUrlHttps'"
Assert-True ($passthroughExit -eq 0 -and $passthroughProbe -match '^[0-9a-f]{40}$') "shim passthrough works: rev-parse HEAD -> '$passthroughProbe'"
Write-Host " git remote get-url shim: $shimPath -> $realGit"
Write-Host " 'remote get-url origin' now reports $RepoUrlHttps"
}
function Read-State {
if (-not (Test-Path -LiteralPath $StatePath)) {
throw "State file not found: $StatePath -- run '-Phase stage' first."
}
return Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
}
function Get-InstalledHead {
return Invoke-Git @("-C", $InstallDir, "rev-parse", "HEAD")
}
function Get-DesktopExe {
foreach ($c in @(
(Join-Path $InstallDir "apps\desktop\release\win-unpacked\Hermes.exe"),
(Join-Path $InstallDir "apps\desktop\release\win-arm64-unpacked\Hermes.exe")
)) {
if (Test-Path -LiteralPath $c) { return $c }
}
return $null
}
# Install-side state snapshot, taken BEFORE Test-HermesRuns can throw: on
# app-update legs the updater runs detached and its transcript lands in the
# product logs and hand-off files, not in this driver. Copy those plus the
# venv entry-point dir while the install is still there to inspect, so a
# failed post-update assertion leaves its evidence in the proof tree.
function Save-InstallSideState([string]$Label) {
$dest = Join-Path $ProofRoot "install-side-$Label"
New-Item -ItemType Directory -Path $dest -Force | Out-Null
$logsDir = Join-Path $HermesHome "logs"
if (Test-Path -LiteralPath $logsDir) {
Copy-Item $logsDir (Join-Path $dest "hermes-logs") -Recurse -Force -ErrorAction SilentlyContinue
}
$resultFile = Join-Path $HermesHome ".hermes-update-result.json"
if (Test-Path -LiteralPath $resultFile) {
Copy-Item $resultFile $dest -Force -ErrorAction SilentlyContinue
}
$venvScripts = Join-Path $InstallDir "venv\Scripts"
if (Test-Path -LiteralPath $venvScripts) {
Get-ChildItem -LiteralPath $venvScripts |
Select-Object Name, Length, LastWriteTime |
Format-Table -AutoSize | Out-String |
Set-Content (Join-Path $dest "venv-scripts-ls.txt")
}
Get-ChildItem -LiteralPath $HermesHome -ErrorAction SilentlyContinue |
Select-Object Name, Length, LastWriteTime |
Format-Table -AutoSize | Out-String |
Set-Content (Join-Path $dest "hermes-home-ls.txt")
}
function Test-HermesRuns([string]$Label) {
Save-InstallSideState $Label
$hermesExe = $null
try {
$hermesExe = Get-SourceHermes $InstallDir
} catch {
# A pre-handoff release cannot complete inside `hermes update`: its
# update path reaches no retired-hook seam, so the update ends with the
# tree at HEAD and no published launcher. The NEXT ordinary startup
# completes it (hermes_bootstrap -> prepare_launch -> sync PM, publish
# launchers, re-exec). Drive that startup here, WITHOUT the lazy-install
# ban, and only when the launcher is missing -- so a healthy update is
# still judged by the strict checks below, and `--version` probes keep
# their ban: a probe must never complete an unfinished update.
Write-Host " no published launcher yet; running the next ordinary startup (this is what completes a pre-handoff release)"
$startupHermes = Get-SourceHermesForStartup $InstallDir
$startupLog = Join-Path $WorkRoot 'logs\post-update-startup.log'
New-Item -ItemType Directory -Force -Path (Split-Path $startupLog) | Out-Null
# prepare_launch reports its progress on stderr, and a native command's
# stderr becomes a terminating NativeCommandError under the wrong
# preference -- which killed this step before the heal could finish.
# Same idiom the --version probe below already uses.
$prevStartupEap = $ErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
& $startupHermes status 2>&1 | Out-File -Encoding UTF8 $startupLog
$startupExit = $LASTEXITCODE
} finally {
$ErrorActionPreference = $prevStartupEap
}
Write-Host " first startup after the update ran (exit $startupExit); the checks below assert the launcher it must have published"
$hermesExe = Get-SourceHermes $InstallDir
}
& $DriverPython -B (Join-Path $AssetsDir 'source_driver.py') --root $InstallDir --launcher $hermesExe --desktop $script:ExpectedDesktop
Assert-True ($LASTEXITCODE -eq 0) "$Label -- read-only install verification (no repair)"
$prevLazy = $env:HERMES_DISABLE_LAZY_INSTALLS
$prevBytecode = $env:PYTHONDONTWRITEBYTECODE
$prevEap = $ErrorActionPreference
try {
$env:HERMES_DISABLE_LAZY_INSTALLS = '1'
$env:PYTHONDONTWRITEBYTECODE = '1'
$ErrorActionPreference = 'Continue'
& $hermesExe --version 2>&1 | ForEach-Object { Write-Host " hermes --version| $_" }
$versionExit = $LASTEXITCODE
} finally {
$env:HERMES_DISABLE_LAZY_INSTALLS = $prevLazy
$env:PYTHONDONTWRITEBYTECODE = $prevBytecode
$ErrorActionPreference = $prevEap
}
Assert-True ($versionExit -eq 0) "$Label -- hermes --version exits 0"
}
# ----------------------------------------------------------------------------
# Script-install arm: the irm | iex one-liner, headless (the install.ps1
# shipped AT the ref under test, run with flags probed from that ref's own
# script text - older releases reject parameters added later).
# ----------------------------------------------------------------------------
# shellcheck source=../e2e-assets/ts-prefix.ps1
. (Join-Path $PSScriptRoot "e2e-assets\ts-prefix.ps1")
. (Join-Path $PSScriptRoot "e2e-assets\source-driver.ps1")
. (Join-Path $AssetsDir 'source-build-env.ps1')
function Write-LogGroup([string]$Title, [string]$LogPath) {
Write-Host "::group::$Title"
if (Test-Path -LiteralPath $LogPath) { Get-Content -LiteralPath $LogPath | Write-Host }
Write-Host "::endgroup::"
}
function Invoke-RefInstaller {
param([string]$Ref, [string]$Label, [switch]$IncludeDesktop)
$script = Join-Path $WorkRoot "install-$Label.ps1"
(Invoke-Git @("-C", $RepoRoot, "show", "$Ref`:scripts/install.ps1")) -join "`n" |
Set-Content -LiteralPath $script -Encoding UTF8
$flags = @("-HermesHome", $HermesHome, "-InstallDir", $InstallDir)
$text = Get-Content -LiteralPath $script -Raw
if ($text -match '\$NonInteractive') { $flags += "-NonInteractive" }
else { $flags += "-SkipSetup" }
if ($IncludeDesktop) {
# The desktop stage is the point of this leg: a ref without the
# parameter is a hard failure, not a silent plain install.
if ($text -notmatch '\$IncludeDesktop') {
throw "E2E ASSERTION FAILED: ref $Ref does not support -IncludeDesktop; this leg cannot mean what it claims"
}
$flags += "-IncludeDesktop"
}
New-Item -ItemType Directory -Path (Join-Path $WorkRoot "logs") -Force | Out-Null
$log = Join-Path $WorkRoot "logs\install-$Label.log"
# uv reads the CURRENT DIRECTORY's project metadata. Run from inside this
# checkout and every interpreter probe in a ref's installer is resolved
# against HEAD's requires-python (3.14), so it refuses the version that ref
# pins and the install fails. A user runs the script from their own
# directory, so give it one that is not a project.
$runDir = Join-Path $WorkRoot "install-cwd"
New-Item -ItemType Directory -Path $runDir -Force | Out-Null
$hangLog = Join-Path $WorkRoot "logs\install-$Label-hang-processes.txt"
$watchdog = Start-HangWatchdog -Minutes $InstallDeadlineMinutes -EvidencePath $hangLog
$prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
Push-Location $runDir
try {
& powershell -NoProfile -ExecutionPolicy Bypass -File $script @flags 2>&1 | Add-TsPrefix | Out-File -Encoding UTF8 $log
$installExit = $LASTEXITCODE
} finally {
Pop-Location
Stop-HangWatchdog $watchdog
}
$ErrorActionPreference = $prevEap
Write-LogGroup "install.ps1 ($Label) transcript" $log
if (Test-Path -LiteralPath $hangLog) {
Write-LogGroup "install.ps1 ($Label) hang evidence (process table, Python stacks)" $hangLog
throw "E2E ASSERTION FAILED: install.ps1 ($Label) was still running after $InstallDeadlineMinutes minutes; the process table and stacks above show where"
}
Assert-True ($installExit -eq 0) "install.ps1 ($Label) exited 0"
}
function Assert-DesktopArtifact([string]$Label) {
Assert-True ($null -ne (Get-DesktopExe)) "$Label -- desktop app built by installer under apps\desktop\release"
}
function Invoke-HermesUpdate {
# --yes reaches the update subcommand only in later
# releases; ask the installed binary, never parse its source.
$hermesExe = Get-SourceHermes $InstallDir
$updateArgs = @("update")
$prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
$helpText = & $hermesExe update --help 2>&1 | Out-String
$helpExit = $LASTEXITCODE
if ($helpExit -ne 0) {
$ErrorActionPreference = $prevEap
throw "Installed update --help failed: $helpText"
}
if ($helpText -match '--yes') { $updateArgs += "--yes" }
New-Item -ItemType Directory -Path (Join-Path $WorkRoot "logs") -Force | Out-Null
$log = Join-Path $WorkRoot "logs\update.log"
$hangLog = Join-Path $WorkRoot "logs\update-hang-processes.txt"
$watchdog = Start-HangWatchdog -Minutes $UpdateDeadlineMinutes -EvidencePath $hangLog
Push-Location $InstallDir
try {
& $hermesExe @updateArgs 2>&1 | Add-TsPrefix | Out-File -Encoding UTF8 $log
$updateExit = $LASTEXITCODE
} finally {
Pop-Location
$ErrorActionPreference = $prevEap
Stop-HangWatchdog $watchdog
}
Write-LogGroup "hermes update transcript" $log
if (Test-Path -LiteralPath $hangLog) {
Write-LogGroup "hermes update hang evidence (process table, Python stacks)" $hangLog
throw "E2E ASSERTION FAILED: hermes update was still running after $UpdateDeadlineMinutes minutes (its output pipe never closed); the process table above shows which process held it"
}
Assert-True ($updateExit -eq 0) "hermes update exited $updateExit (expected 0)"
}
# install.ps1 (with -IncludeDesktop) and `hermes update` normally finish in
# under 25 minutes. Past these deadlines the watchdog records every process
# (pid, parent, start time, command line) plus a py-spy stack of each Python
# process, then stops this leg's processes, so a hang fails with evidence --
# whether the product itself is stuck or a detached child still holds its
# output pipe -- instead of being cancelled blind at the job cap.
$InstallDeadlineMinutes = 40
$UpdateDeadlineMinutes = 45
function Start-HangWatchdog([int]$Minutes, [string]$EvidencePath) {
if (Test-Path -LiteralPath $EvidencePath) { Remove-Item -LiteralPath $EvidencePath -Force }
$exclude = @()
if ($script:ChatMock) { $exclude += $script:ChatMock.Id }
$homes = @($HermesHome, [System.IO.Path]::GetFullPath($HermesHome)) | Select-Object -Unique
# py-spy is installed next to the driver's Python by the workflow.
$pyspy = Join-Path (Split-Path $DriverPython) 'py-spy.exe'
Start-Job -ArgumentList $PID, $Minutes, $EvidencePath, $homes, $exclude, $pyspy -ScriptBlock {
param($driverPid, $minutes, $out, $homes, $exclude, $pyspy)
Start-Sleep -Seconds ($minutes * 60)
$all = @(Get-CimInstance Win32_Process)
$row = { param($p) '{0,7} <- {1,7} {2:yyyy-MM-ddTHH:mm:ss} {3}' -f $p.ProcessId, $p.ParentProcessId, $p.CreationDate, $(if ($p.CommandLine) { $p.CommandLine } else { $p.Name }) }
$tree = @()
$frontier = @($driverPid)
while ($frontier.Count -gt 0) {
$children = @($all | Where-Object { ($frontier -contains $_.ParentProcessId) -and $_.ProcessId -ne $PID -and -not ($exclude -contains $_.ProcessId) })
$tree += $children
$frontier = @($children | ForEach-Object { $_.ProcessId })
}
$leg = @($all | Where-Object {
$cmd = $_.CommandLine
$_.ProcessId -ne $PID -and $cmd -and @($homes | Where-Object { $cmd.IndexOf($_, [StringComparison]::OrdinalIgnoreCase) -ge 0 }).Count -gt 0
})
$lines = @("still running after $minutes minutes", '', "== descendants of the driver (pid $driverPid) ==")
$lines += @($tree | ForEach-Object { & $row $_ })
$lines += @('', "== processes naming this leg's HERMES_HOME ==")
$lines += @($leg | ForEach-Object { & $row $_ })
$lines += @('', '== every process, oldest first ==')
$lines += @($all | Sort-Object CreationDate | ForEach-Object { & $row $_ })
if (Test-Path -LiteralPath $pyspy) {
foreach ($p in (@($tree) + @($leg) | Where-Object { $_.Name -match '^(python|pythonw|hermes)' } | Sort-Object ProcessId -Unique)) {
$lines += @('', "== py-spy dump --pid $($p.ProcessId) ($($p.Name)) ==")
$lines += @(& $pyspy dump --pid $p.ProcessId --nonblocking 2>&1 | ForEach-Object { "$_" })
}
} else {
$lines += @('', "(no Python stacks: $pyspy is missing)")
}
Set-Content -LiteralPath $out -Value $lines -Encoding UTF8
foreach ($victim in (@($tree) + @($leg))) {
Stop-Process -Id $victim.ProcessId -Force -ErrorAction SilentlyContinue
}
}
}
function Stop-HangWatchdog($Job) {
if ($Job) {
Stop-Job -Job $Job -ErrorAction SilentlyContinue
Remove-Job -Job $Job -Force -ErrorAction SilentlyContinue
}
}
function Invoke-ManualCardUpdate([string]$ReceiptPath, [string]$TargetSha) {
Assert-True (Test-Path -LiteralPath $ReceiptPath) "manual update card produced a receipt"
$manual = Get-Content -LiteralPath $ReceiptPath -Raw | ConvertFrom-Json
Assert-True ($manual.command -match '^hermes update(?:\s|$)') "manual update card instructed hermes update"
Invoke-HermesUpdate
Assert-True ((Get-InstalledHead) -eq $TargetSha) "manual update landed on target commit"
Test-HermesRuns "post-manual-update"
Assert-True ($null -ne (Get-DesktopExe)) "Hermes.exe still present after manual update"
}
function Clear-HistoricalInstallerChurn {
# The GUI driver refuses a dirty source tree. v2026.7.1's installer leaves
# its own state there: `npm install` rewrites package-lock.json on Windows
# (later installers use `npm ci`, #112378) and v2026.6.19-era installers
# write an unignored .install_method. Installers through v2026.9.24 also
# clone under Git for Windows' system core.autocrlf=true and pin false only
# afterwards, leaving CRLF copies of every text file .gitattributes does not
# pin to LF (*.mdx, LICENSE, ...). The stat cache hides them except the few
# written in the index's racy timestamp window, which read as modified with
# identical index/HEAD blobs. Undo only that installer-generated state in
# this disposable clone; any other change still fails, listed.
# porcelain=v2: Invoke-Git trims output, which would eat v1's leading " M".
$lines = @((Invoke-Git @("-C", $InstallDir, "-c", "core.quotepath=false", "status", "--porcelain=v2", "--untracked-files=all")) -split "\r?\n" |
Where-Object { $_ })
if ($lines.Count -eq 0) { return }
Write-Host " source status before GUI update:"
$lines | ForEach-Object { Write-Host " $_" }
# --numstat honours --ignore-cr-at-eol (--name-only does not): a modified
# path with no record and an unchanged mode differs from its index blob
# only by CRs.
$content = @{}
(Invoke-Git @("-C", $InstallDir, "-c", "core.quotepath=false", "diff", "--numstat", "--ignore-cr-at-eol")) -split "\r?\n" |
Where-Object { $_ } | ForEach-Object { $content[($_ -split "`t", 3)[2]] = $true }
$locks = @(); $other = @()
foreach ($line in $lines) {
$fields = $line -split " ", 9
if ($fields[0] -eq "1" -and $fields[1] -eq ".M" -and $fields.Count -eq 9 -and
($fields[8] -eq "package-lock.json" -or $fields[8] -like "*/package-lock.json" -or
($fields[4] -eq $fields[5] -and -not $content.ContainsKey($fields[8])))) {
$locks += $fields[8]
} elseif ($line -eq "? .install_method") {
Add-Content -LiteralPath (Join-Path $InstallDir ".git\info\exclude") -Value "/.install_method"
} else {
$other += $line
}
}
Assert-True ($other.Count -eq 0) "installed source has only installer-generated changes (other: $($other -join '; '))"
if ($locks.Count) {
# From a file: a v2026.7.1 clone churns hundreds of paths, and passing
# them as arguments overflows the Windows command line ("The filename
# or extension is too long"). NUL-separated, literal: no quoting or
# glob magic applies to a path.
$pathspecs = Join-Path $WorkRoot "installer-churn.pathspec"
[System.IO.File]::WriteAllText($pathspecs, (($locks -join "`0") + "`0"), (New-Object System.Text.UTF8Encoding $false))
Invoke-Git @("--literal-pathspecs", "-C", $InstallDir, "checkout", "--pathspec-from-file=$pathspecs", "--pathspec-file-nul") | Out-Null
}
$left = @((Invoke-Git @("-C", $InstallDir, "status", "--porcelain", "--untracked-files=all")) -split "\r?\n" |
Where-Object { $_ })
Assert-True ($left.Count -eq 0) "undid only installer-generated source churn before the GUI update"
}
function Invoke-HermesDesktopAppUpdate([string]$TargetSha) {
# The hermes-desktop launch surface: `hermes desktop` runs its whole
# real pipeline; the driver intercepts the product's final spawn
# (argv/cwd/env captured by e2e-assets/launch-capture/sitecustomize.py)
# and re-executes it under Playwright, which clicks Update now.
$hermesExe = Get-SourceHermes $InstallDir
$spec = Join-Path $WorkRoot "launch-spec.json"
New-Item -ItemType Directory -Path (Join-Path $WorkRoot "logs") -Force | Out-Null
$log = Join-Path $WorkRoot "logs\desktop-launch-capture.log"
$capDir = Join-Path $AssetsDir "launch-capture"
$prevPy = $env:PYTHONPATH
$prevCap = $env:HERMES_E2E_CAPTURE_LAUNCH
$env:PYTHONPATH = if ($prevPy) { "$capDir;$prevPy" } else { $capDir }
$env:HERMES_E2E_CAPTURE_LAUNCH = $spec
$prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
Push-Location $InstallDir
try {
if ($hermesExe.StartsWith((Join-Path $InstallDir '.hermes'), [StringComparison]::OrdinalIgnoreCase)) {
# The PM launcher runs its interpreter with -I, so PYTHONPATH never
# imports sitecustomize. Same as installer-script-e2e.sh: ask the
# launcher for its own isolated command and inject the hook into it.
& $DriverPython -I (Join-Path $capDir 'pm-launch.py') $hermesExe $spec 2>&1 | Add-TsPrefix | Out-File -Encoding UTF8 $log
} else {
# Pre-PM venv console scripts load sitecustomize from PYTHONPATH.
& $hermesExe desktop 2>&1 | Add-TsPrefix | Out-File -Encoding UTF8 $log
}
$capExit = $LASTEXITCODE
} finally {
Pop-Location
$ErrorActionPreference = $prevEap
$env:PYTHONPATH = $prevPy
$env:HERMES_E2E_CAPTURE_LAUNCH = $prevCap
}
Write-LogGroup "hermes desktop (launch capture) transcript" $log
Assert-True ($capExit -eq 0) "hermes desktop exited 0 during launch capture"
Assert-True (Test-Path -LiteralPath "$spec.captured") "a launch was actually captured (exit 0 without a launch must not pass)"
Clear-HistoricalInstallerChurn
$node = $DriverNode
$chatOut = Join-Path $ProofRoot 'update-window'
Remove-Item -LiteralPath (Join-Path $chatOut 'desktop-chat-old.json') -Force -ErrorAction SilentlyContinue
$script:ChatFailure = $true
$prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
Push-Location $WorkRoot
try {
# 30 min, not the 10 min default: from v2026.9.24 the app's update
# runs the historical venv->PM takeover plus a full Desktop rebuild
# (9m43s measured), like the 35 min open-app-update wait below.
& $node (Join-Path $AssetsDir "launch-from-spec.mjs") --spec $spec `
--old-sha (Read-State).old --chat-out $chatOut --mock-url $env:HERMES_E2E_MOCK_URL `
--result (Join-Path $HermesHome ".hermes-update-result.json") `
--expect-sha $TargetSha --repo-dir $InstallDir --timeout-ms 1800000 2>&1 |
ForEach-Object { Write-Host " pw| $_" }
$driveExit = $LASTEXITCODE
} finally {
Pop-Location
$ErrorActionPreference = $prevEap
}
Confirm-OldChat $chatOut
$manualReceipt = Join-Path $chatOut 'manual-update.json'
if ($driveExit -eq 42) {
Invoke-ManualCardUpdate $manualReceipt $TargetSha
return
}
Assert-True ($driveExit -eq 0) "app driven via captured hermes desktop spec; update completed"
# The production updater relaunches Hermes. Close that verified window
# normally so the test-owned checkpoint starts and owns its own backend.
$desktopExe = Get-DesktopExe
$deadline = (Get-Date).AddMinutes(5)
$windows = @()
while ((Get-Date) -lt $deadline) {
$windows = @(Get-VerifiedDesktopWindows $desktopExe)
if ($windows.Count -eq 1) { break }
Start-Sleep -Seconds 2
}
Assert-True ($windows.Count -eq 1) "updated desktop relaunched exactly one verified window"
$script:ChatFailure = $true
Close-VerifiedDesktop $desktopExe $windows[0].Id
}
# Evidence for a GUI-driver failure, taken while the installer is still alive: which Hermes
# processes exist (was Hermes.exe ever started, and by whom), the installer's thread states,
# and a full memory dump of the installer. The installer's tracing log is buffered and never
# reaches disk when the job kills it; the dump still holds it. A Launch that left the
# installer on LAUNCHING had no other trace (tests/install/e2e-assets/install-and-launch.ahk).
function Save-GuiDriverFailureEvidence([System.Diagnostics.Process]$Installer, [string]$OutDir) {
New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
Get-CimInstance Win32_Process |
Where-Object { $_.Name -match '^(hermes|msedgewebview2|python|uv|git|node)' -or $_.ParentProcessId -eq $Installer.Id } |
Sort-Object CreationDate |
Select-Object ProcessId, ParentProcessId, CreationDate, Name, CommandLine |
Format-Table -AutoSize -Wrap | Out-String -Width 400 |
Tee-Object -FilePath (Join-Path $OutDir "processes.txt") | Write-Host
if ($Installer.HasExited) {
Write-Host " Hermes-Setup.exe already exited (code $($Installer.ExitCode) at $($Installer.ExitTime))"
return
}
$Installer.Refresh()
$Installer.Threads |
Select-Object Id, ThreadState, WaitReason, StartTime, TotalProcessorTime |
Format-Table -AutoSize | Out-String -Width 200 |
Tee-Object -FilePath (Join-Path $OutDir "installer-threads.txt") | Write-Host
if (-not ('HdE2E.Dump' -as [type])) {
Add-Type -Namespace HdE2E -Name Dump -MemberDefinition @'
[DllImport("dbghelp.dll", SetLastError = true)]
public static extern bool MiniDumpWriteDump(IntPtr hProcess, uint processId, Microsoft.Win32.SafeHandles.SafeFileHandle hFile, uint dumpType, IntPtr exceptionParam, IntPtr userStreamParam, IntPtr callbackParam);
'@
}
$dumpPath = Join-Path $OutDir "Hermes-Setup.dmp"
$file = [System.IO.File]::Create($dumpPath)
try {
# MiniDumpWithFullMemory | MiniDumpWithHandleData | MiniDumpWithThreadInfo
$ok = [HdE2E.Dump]::MiniDumpWriteDump($Installer.Handle, [uint32]$Installer.Id, $file.SafeFileHandle, 0x1006, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero)
$err = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
}
finally {
$file.Close()
}
if ($ok) { Write-Host " Hermes-Setup.exe dump: $dumpPath ($([math]::Round((Get-Item $dumpPath).Length / 1MB, 1)) MB)" }
else { Write-Host " Hermes-Setup.exe dump failed (Win32 error $err)" }
}
function Save-DesktopScreenshot([string]$OutFile) {
# Single full-desktop screenshot (primary screen).
try {
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$bmp.Save($OutFile, [System.Drawing.Imaging.ImageFormat]::Png)
$gfx.Dispose(); $bmp.Dispose()
Write-Host " desktop screenshot: $OutFile"
} catch {
Write-Host " WARNING: desktop screenshot failed: $($_.Exception.Message)"
}
}
function Start-DesktopRecorder([string]$OutDir) {
# Rolling desktop capture: one PNG every 3s from a detached PowerShell,
# capped at 800 frames (~40 min). Proof that survives any step failure.
New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
$script = Join-Path $WorkRoot "recorder.ps1"
@'
param([string]$OutDir)
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
for ($i = 0; $i -lt 800; $i++) {
if (Test-Path (Join-Path $OutDir "STOP")) { break }
try {
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$bmp.Save((Join-Path $OutDir ("frame-{0:D4}.png" -f $i)), [System.Drawing.Imaging.ImageFormat]::Png)
$gfx.Dispose(); $bmp.Dispose()
} catch {}
Start-Sleep -Seconds 3
}
'@ | Set-Content -LiteralPath $script -Encoding UTF8
$proc = Start-Process -FilePath "powershell.exe" `
-ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $script, "-OutDir", $OutDir `
-WindowStyle Hidden -PassThru
Write-Host " desktop recorder started (pid $($proc.Id)) -> $OutDir"
return $proc
}
function Stop-DesktopRecorder($proc, [string]$OutDir) {
try { Set-Content -LiteralPath (Join-Path $OutDir "STOP") -Value "stop" } catch {}
if ($proc) {
try { $proc.WaitForExit(8000) | Out-Null } catch {}
try { if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force } } catch {}
}
}
function Stop-HermesAppProcesses([string]$Label) {
# Close the desktop app the blunt way between phases (a user quitting).
# Only Hermes.exe (Electron) -- never hermes.exe (the venv CLI shim).
$procs = @(Get-Process -Name "Hermes" -ErrorAction SilentlyContinue)
foreach ($p in $procs) {
try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {}
}
if ($procs.Count -gt 0) {
Write-Host " [$Label] stopped $($procs.Count) Hermes.exe process(es)"
Start-Sleep -Seconds 3
}
}
# ----------------------------------------------------------------------------
# Phase: stage -- serve.git with `main` at OLD (advanced to HEAD by update-gui)
# ----------------------------------------------------------------------------
# Mirror of resolve_update_ref's NEXT arm in e2e-assets/installer-common.sh:
# a synthetic child of $Parent whose tree adds one marker file (a real diff,
# not an empty fast-forward), written to the object store only -- no ref, no
# worktree change. A local `clone --bare` copies objects/ wholesale, which is
# how it reaches serve.git. A throwaway index stands in for mktree so no
# NUL-delimited stdin has to cross PowerShell's native pipe.
function New-NextCommit([string]$Repo, [string]$Parent) {
$marker = Join-Path $WorkRoot "next-marker.txt"
Set-Content -LiteralPath $marker -Encoding ASCII -Value "synthetic next commit for the HEAD -> NEXT install E2E leg"
$blob = Invoke-Git @("-C", $Repo, "hash-object", "-w", "--no-filters", $marker)
$saved = @{}
$vars = @{
GIT_INDEX_FILE = (Join-Path $WorkRoot "next.index")
GIT_AUTHOR_NAME = "Hermes E2E"; GIT_AUTHOR_EMAIL = "e2e@hermes.invalid"
GIT_COMMITTER_NAME = "Hermes E2E"; GIT_COMMITTER_EMAIL = "e2e@hermes.invalid"
}
foreach ($k in $vars.Keys) { $saved[$k] = [Environment]::GetEnvironmentVariable($k); [Environment]::SetEnvironmentVariable($k, $vars[$k]) }
try {
Invoke-Git @("-C", $Repo, "read-tree", $Parent) | Out-Null
Invoke-Git @("-C", $Repo, "update-index", "--add", "--cacheinfo", "100644,$blob,.hermes-e2e-next") | Out-Null
$tree = Invoke-Git @("-C", $Repo, "write-tree")
return Invoke-Git @("-C", $Repo, "commit-tree", $tree, "-p", $Parent, "-m", "e2e: synthetic next commit")
} finally {
foreach ($k in $vars.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) }
Remove-Item -LiteralPath $vars.GIT_INDEX_FILE -Force -ErrorAction SilentlyContinue
}
}
function Invoke-PhaseStage {
Write-Step "STAGE: bare serve repo, main -> OLD (install base)"
if (Test-Path -LiteralPath $WorkRoot) {
Remove-Item -LiteralPath $WorkRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $WorkRoot "logs") -Force | Out-Null
# The purge above deleted the redirect gitconfig; re-arm it so the
# bare-clone below (and everything after) sees the redirect file.
Set-GitRedirect
# OLD: explicit -InstallRef, or the newest release tag -- the version a
# user who installed on release day is on.
$oldRef = $InstallRef
if (-not $oldRef -or $oldRef -eq "auto") {
# Parens matter: without them PowerShell binds -split as an
# argument to Invoke-Git instead of an operator on its result.
$tagList = Invoke-Git @("-C", $RepoRoot, "tag", "--list", "v*", "--sort=-creatordate")
$oldRef = ($tagList -split "\r?\n" | Select-Object -First 1)
if (-not $oldRef) { throw "no v* release tags in the checkout and no -InstallRef given -- cannot pick an OLD version" }
}
$old = Invoke-Git @("-C", $RepoRoot, "rev-parse", "$oldRef^{commit}")
Write-Host " OLD ($oldRef): $old"
# NEXT is minted before the clone so it rides along into serve.git.
$targetLabel = $UpdateRef
$current = if ($UpdateRef -eq "NEXT") { New-NextCommit $RepoRoot $old } else {
Invoke-Git @("-C", $RepoRoot, "rev-parse", "${UpdateRef}^{commit}")
}
Write-Host " update target ($targetLabel): $current"
Assert-True ($old -ne $current) "OLD differs from $targetLabel (an update is genuinely available)"
# Bare-clone the checkout: this is the repo the installer and updater
# actually talk to. Local-path clone hardlinks objects, so it's fast
# even for full history. The published installer carries NO commit pin
# (Pin { commit: None, branch: main }) -- it installs whatever `main`
# serves, so staging OLD means parking `main` there; the update phase
# advances it to HEAD.
Invoke-Git @("clone", "--bare", "--quiet", $RepoRoot, $ServeRepo) | Out-Null
Invoke-Git @("-C", $ServeRepo, "cat-file", "-e", "$current^{commit}") | Out-Null
Invoke-Git @("-C", $ServeRepo, "update-ref", "refs/heads/main", $old) | Out-Null
Invoke-Git @("-C", $ServeRepo, "symbolic-ref", "HEAD", "refs/heads/main") | Out-Null
# Belt-and-braces: SOME installer builds do bake a -Commit pin. A pinned
# sha is in serve.git's history but not at a ref tip, so the redirected
# fetch needs any-SHA1 upload-pack permission (GitHub grants the
# equivalent for fetch of reachable commits).
Invoke-Git @("-C", $ServeRepo, "config", "uploadpack.allowAnySHA1InWant", "true") | Out-Null
Write-Host " serve.git: uploadpack.allowAnySHA1InWant=true (installer commit pin, if any)"
@{ old = $old; old_ref = $oldRef; current = $current; target_label = $targetLabel } |
ConvertTo-Json | Set-Content -LiteralPath $StatePath -Encoding UTF8
Write-Host " state written: $StatePath"
New-Item -ItemType Directory -Path $ProofRoot -Force | Out-Null
}
# ----------------------------------------------------------------------------
# Phase: install-gui -- website Hermes-Setup.exe, headed, AHK-driven
# ----------------------------------------------------------------------------
function Invoke-PhaseInstallGui {
param(
# "install" (first run, must land on OLD) or "update" (re-run over an
# existing install after serve.git advanced, must land on the target).
[string]$Mode = "install",
[string]$ExpectedSha = "",
[string]$ExpectedLabel = ""
)
$state = Read-State
if ($Mode -eq "install") {
$ExpectedSha = $state.old
$ExpectedLabel = "OLD ($($state.old_ref))"
}
Write-Step "$($Mode.ToUpper()) (GUI): Hermes-Setup.exe from the website, headed, AHK clicks"
$proof = Join-Path $ProofRoot $(if ($Mode -eq "install") { "install-gui" } else { "update-gui-installer" })
New-Item -ItemType Directory -Path $proof -Force | Out-Null
# The production installer binary comes from the website. Pair its script
# input with the source revision it will materialize. A branch-following
# installer can otherwise run today's install.ps1 against OLD, whose tree
# legitimately lacks helpers added later (for example
# apps/desktop/scripts/ensure-rolldown-binding.mjs). The bootstrap's public
# dev-source seam changes only script resolution. The GUI binary and cloned
# source remain the real artifacts under test.
$bootstrapRoot = Join-Path $WorkRoot "bootstrap-source-$Mode"
$bootstrapScripts = Join-Path $bootstrapRoot "scripts"
New-Item -ItemType Directory -Path $bootstrapScripts -Force | Out-Null
$installScript = Join-Path $bootstrapScripts "install.ps1"
(Invoke-Git @("-C", $RepoRoot, "show", "$ExpectedSha`:scripts/install.ps1")) -join "`n" |
Set-Content -LiteralPath $installScript -Encoding UTF8
Copy-Item $installScript (Join-Path $proof "bootstrap-install-script.ps1") -Force
$scriptBlob = Invoke-Git @("-C", $RepoRoot, "rev-parse", "$ExpectedSha`:scripts/install.ps1")
@(
"source_commit=$ExpectedSha"
"script_blob=$scriptBlob"
) | Set-Content -LiteralPath (Join-Path $proof "bootstrap-install-script.txt") -Encoding ASCII
Write-Host " bootstrap script is scripts/install.ps1 from $ExpectedLabel ($ExpectedSha)"
$setupExe = Join-Path $WorkRoot "Hermes-Setup.exe"
if (-not (Test-Path -LiteralPath $setupExe)) {
Write-Host " downloading $SetupExeUrl"
Invoke-WebRequest -Uri $SetupExeUrl -OutFile $setupExe
}
Assert-True ((Get-Item $setupExe).Length -gt 1MB) "Hermes-Setup.exe downloaded ($([math]::Round((Get-Item $setupExe).Length / 1MB, 1)) MB)"
# AutoHotkey v2, portable zip (no installer, no winget flakes).
$ahkExe = Join-Path $AhkDir "AutoHotkey64.exe"
if (-not (Test-Path -LiteralPath $ahkExe)) {
$zip = Join-Path $WorkRoot "ahk.zip"
Invoke-WebRequest -Uri "https://github.com/AutoHotkey/AutoHotkey/releases/download/v2.0.19/AutoHotkey_2.0.19.zip" -OutFile $zip
Expand-Archive -Path $zip -DestinationPath $AhkDir -Force
}
Assert-True (Test-Path -LiteralPath $ahkExe) "AutoHotkey64.exe available"
# AHK script + button templates side by side (ImageSearch resolves
# relative to the script dir).
Copy-Item -Path (Join-Path $AssetsDir "install-and-launch.ahk"), (Join-Path $AssetsDir "install-button.png"), (Join-Path $AssetsDir "launch-button.png") -Destination $AhkDir -Force
$env:HERMES_HOME = $HermesHome
New-Item -ItemType Directory -Path $HermesHome -Force | Out-Null
$recorder = Start-DesktopRecorder (Join-Path $proof "desktop-frames")
$ahkLog = Join-Path $proof "ahk.log"
try {
Save-DesktopScreenshot (Join-Path $proof "00-before-installer.png")
# Launch the real headed installer. Scope the paired script source to
# this process only so later product launches cannot inherit it.
$previousSetupSource = $env:HERMES_SETUP_DEV_REPO_ROOT
$env:HERMES_SETUP_DEV_REPO_ROOT = $bootstrapRoot
try {
$installer = Start-Process -FilePath $setupExe -PassThru
}
finally {
if ($null -eq $previousSetupSource) {
Remove-Item Env:HERMES_SETUP_DEV_REPO_ROOT -ErrorAction SilentlyContinue
}
else {
$env:HERMES_SETUP_DEV_REPO_ROOT = $previousSetupSource
}
}
Write-Host " Hermes-Setup.exe launched (pid $($installer.Id))"
# Drive it: Install click -> wait -> Launch click -> Hermes.exe window.
# Arg 3 lets the AHK script use the installer's own log as the
# install-finished fallback signal.
$ahk = Start-Process -FilePath $ahkExe `
-ArgumentList (Join-Path $AhkDir "install-and-launch.ahk"), $ahkLog, "Hermes-Setup.exe", (Join-Path $HermesHome "logs\bootstrap-installer.log") `
-PassThru
# Install on a cold runner takes a while; the AHK script's own inner
# timeout (45 min on the Launch wait) is the effective budget.
if (-not $ahk.WaitForExit(50 * 60 * 1000)) {
Stop-Process -Id $ahk.Id -Force -ErrorAction SilentlyContinue
throw "AutoHotkey driver did not finish within 50 minutes"
}
if (Test-Path -LiteralPath $ahkLog) {
Get-Content -LiteralPath $ahkLog | ForEach-Object { Write-Host " ahk| $_" }
}
if ($ahk.ExitCode -ne 0) {
# Evidence must not replace the driver's own failure below.
try { Save-GuiDriverFailureEvidence $installer (Join-Path $proof "driver-failure") }
catch { Write-Host " evidence capture failed: $_" }
}
Assert-True ($ahk.ExitCode -eq 0) "AutoHotkey driver exited 0 (Install clicked, Launch clicked, app window seen)"
Save-DesktopScreenshot (Join-Path $proof "01-app-launched.png")
# The Launch hand-off under test: the app the installer spawned must
# actually be running.
Assert-True ($null -ne (Get-Process -Name "Hermes" -ErrorAction SilentlyContinue)) "Hermes.exe process is running (installer Launch hand-off worked)"
# Installer should have exited after Launch.
if (-not $installer.HasExited) {
Start-Sleep -Seconds 10
}
Assert-True $installer.HasExited "Hermes-Setup.exe exited after Launch"
}
finally {
Stop-DesktopRecorder $recorder (Join-Path $proof "desktop-frames")
# Surface the installer's own log win or lose, full and folded.
$bootLog = Join-Path $HermesHome "logs\bootstrap-installer.log"
if (Test-Path -LiteralPath $bootLog) {
Write-Host "::group::bootstrap-installer.log"
Get-Content -LiteralPath $bootLog | Write-Host
Write-Host "::endgroup::"
Copy-Item $bootLog $proof -Force -ErrorAction SilentlyContinue
}
}
# The installer cloned/updated from serve.git's `main`; the phase's
# expected sha says where that must land (install: OLD; update: HEAD).
$installedSha = Get-InstalledHead
Write-Host " installer landed on: $installedSha (expected $ExpectedLabel = $ExpectedSha)"
Assert-True ($installedSha -eq $ExpectedSha) "installed checkout is at $ExpectedLabel"
if ($Mode -eq "install") {
Assert-True ($installedSha -ne $state.current) "installed checkout differs from HEAD (an update is genuinely available)"
}
Test-HermesRuns "post-$Mode-gui"
Assert-True ($null -ne (Get-DesktopExe)) "packaged Desktop Hermes.exe exists"
# The installer Launch proof above must pass before a test-owned launch.
$script:ChatFailure = $true
Close-VerifiedDesktop (Get-DesktopExe)
# The installer-launched renderer boots before the journey seeds its local
# mock provider. Its disposable userData can therefore persist the release's
# default OpenRouter choice and override the mock on the checkpoint relaunch.
# Reset only this driver-owned pre-checkpoint state; OLD -> HEAD keeps the
# state created by the checkpoint itself.
if (Test-Path -LiteralPath $env:HERMES_DESKTOP_USER_DATA_DIR) {
Remove-Item -LiteralPath $env:HERMES_DESKTOP_USER_DATA_DIR -Recurse -Force
}
New-Item -ItemType Directory -Path $env:HERMES_DESKTOP_USER_DATA_DIR -Force | Out-Null
$chatPhase = if ($Mode -eq 'install') { 'old' } else { 'new' }
@{ phase=$chatPhase; launch='post-installer-launch'; handoffProof=$proof } | ConvertTo-Json |
Set-Content (Join-Path $ProofRoot "desktop-chat-$chatPhase-launch.json")
Invoke-DesktopCheckpoint $chatPhase $ExpectedSha 'desktop-installer@latest'
}
# ----------------------------------------------------------------------------
# Phase: update-gui -- OLD -> HEAD through the selected route
# ----------------------------------------------------------------------------
function Invoke-GuiUpdateDesktopRoute([string]$TargetSha) {
Write-Step "UPDATE (GUI, route=desktop): advance served main -> $TargetSha, click Update now"
$proof = Join-Path $ProofRoot "update-gui"
New-Item -ItemType Directory -Path $proof -Force | Out-Null
$env:HERMES_HOME = $HermesHome
# The update becomes available the way it does for a real user: the
# remote's main moves forward. (Install ran against main = OLD.)
Invoke-Git @("-C", $ServeRepo, "update-ref", "refs/heads/main", $TargetSha) | Out-Null
Write-Host " serve.git main advanced to $TargetSha"
$desktopExe = Get-DesktopExe
Assert-True ($null -ne $desktopExe) "packaged Hermes.exe present before update"
$resultPath = Join-Path $HermesHome ".hermes-update-result.json"
$markerPath = Join-Path $HermesHome ".hermes-update-in-progress"
Remove-Item -LiteralPath $resultPath -Force -ErrorAction SilentlyContinue
$node = $DriverNode
$recorder = Start-DesktopRecorder (Join-Path $proof "desktop-frames")
try {
# Launch the installed app and click through Settings -> About ->
# Update now. Exit 0 = the app quit for the updater hand-off.
$driver = Join-Path $AssetsDir 'drive-update.cjs'
Remove-Item -LiteralPath (Join-Path $proof 'desktop-chat-old.json') -Force -ErrorAction SilentlyContinue
$script:ChatFailure = $true
Push-Location $WorkRoot
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $node $driver $desktopExe $proof (Read-State).old $TargetSha 2>&1 |
ForEach-Object { Write-Host " $_" }
$driveExit = $LASTEXITCODE
} finally {
Pop-Location
$ErrorActionPreference = $prevEap
}
Confirm-OldChat $proof
$manualReceipt = Join-Path $proof 'manual-update.json'
if ($driveExit -eq 42) {
Invoke-ManualCardUpdate $manualReceipt $TargetSha
Invoke-DesktopCheckpoint 'new' $TargetSha 'open-app-update-manual'
return
}
Assert-True ($driveExit -eq 0) "GUI driver clicked Update now and the app quit for hand-off"
# The detached updater (spawned by the app, NOT by us) now runs
# `hermes update` + desktop rebuild + relaunch. Which updater depends
# on the installed checkout, and BOTH are production paths:
# * checkouts shipping scripts/desktop-update.ps1 -> that script,
# which writes .hermes-update-result.json on every exit;
# * older checkouts -> the staged hermes-setup.exe --update flow,
# which does NOT write the result JSON.
# So: poll for COMPLETION = (result JSON) OR (checkout reached the
# target sha AND the marker is gone). The sha/marker/hermes/relaunch
# asserts below are the hard gate either way; the JSON is asserted
# only when the script path produced it.
#
# The update pulls a large diff AND does a full Electron desktop
# rebuild (vite + electron-builder) plus a uv sync; a WORKING updater
# finishes well under 35 minutes on these runners (slowest observed
# leg anywhere in the matrix: 29m end to end). A wedged updater never
# finishes at any bound, so a longer wait only delays the report.
# The desktop-build output goes to logs/update.log (not the streamed
# handoff log), so we tail update.log here to show progress.
Write-Host " waiting for the detached updater to finish (up to 35 min) ..."
$updateLog = Join-Path $HermesHome "logs\update.log"
$updateLogPos = 0
$deadline = (Get-Date).AddMinutes(35)
while ((Get-Date) -lt $deadline) {
if (Test-Path -LiteralPath $resultPath) { break }
$head = ""
try { $head = Get-InstalledHead } catch {}
if ($head -eq $TargetSha -and -not (Test-Path -LiteralPath $markerPath)) { break }
# Tail any new update.log lines so the desktop-rebuild phase is
# visible in the CI step output.
if (Test-Path -LiteralPath $updateLog) {
try {
$lines = Get-Content -LiteralPath $updateLog -ErrorAction SilentlyContinue
if ($lines.Count -gt $updateLogPos) {
$lines[$updateLogPos..($lines.Count - 1)] | ForEach-Object { Write-Host " update.log| $_" }
$updateLogPos = $lines.Count
}
} catch {}
}
Start-Sleep -Seconds 20
}
if (Test-Path -LiteralPath $resultPath) {
$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
Write-Host " updater result: ok=$($result.ok) code=$($result.exit_code) msg=$($result.message)"
Assert-True ([bool]$result.ok) "updater result ok=true"
} else {
Write-Host " (no result JSON -- staged-binary updater path; relying on sha/marker/relaunch asserts)"
}
# Marker may briefly outlive the result write; allow it a moment.
$mDeadline = (Get-Date).AddMinutes(2)
while ((Get-Date) -lt $mDeadline -and (Test-Path -LiteralPath $markerPath)) { Start-Sleep -Seconds 5 }
Assert-True (-not (Test-Path -LiteralPath $markerPath)) "update marker cleaned up"
Assert-True ((Get-InstalledHead) -eq $TargetSha) "checkout landed on target commit"
Test-HermesRuns "post-update"
Assert-True ($null -ne (Get-DesktopExe)) "Hermes.exe still present after update"
# The production hand-off relaunches the desktop (RelaunchExe).
# A relaunched window is the user-visible proof the update loop closed.
Write-Host " waiting for the relaunched Hermes.exe ..."
$rDeadline = (Get-Date).AddMinutes(5)
$relaunched = $null
while ((Get-Date) -lt $rDeadline) {
$relaunched = Get-Process -Name "Hermes" -ErrorAction SilentlyContinue
if ($relaunched) { break }
Start-Sleep -Seconds 5
}
Assert-True ($null -ne $relaunched) "updater relaunched the desktop app"
Start-Sleep -Seconds 12 # let the window paint for the screenshot
# Foreground the relaunched Hermes window so the proof screenshot
# captures IT, not whatever else is on top (the full-desktop grab is
# otherwise at the mercy of z-order -- an earlier run caught VS Code).
$mainProc = $null
try {
$mainProc = Get-Process -Name "Hermes" -ErrorAction SilentlyContinue |
Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1
if ($mainProc) {
Add-Type -Namespace HdE2E -Name Win -MemberDefinition @'
[System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr h);
[System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr h, int n);
'@ -ErrorAction SilentlyContinue
[HdE2E.Win]::ShowWindow($mainProc.MainWindowHandle, 9) | Out-Null # SW_RESTORE
[HdE2E.Win]::SetForegroundWindow($mainProc.MainWindowHandle) | Out-Null
Start-Sleep -Seconds 2
}
} catch {}
Assert-True ($null -ne $mainProc) "relaunch has a foregroundable desktop window"
Save-DesktopScreenshot (Join-Path $proof "99-relaunched-desktop.png")
# Native relaunch and read-only product verification already passed.
$script:ChatFailure = $true
Close-VerifiedDesktop (Get-DesktopExe) $mainProc.Id
@{ phase='new'; launch='post-update-launch'; automaticRelaunch=$true } | ConvertTo-Json |
Set-Content (Join-Path $ProofRoot 'desktop-chat-new-launch.json')
Invoke-DesktopCheckpoint 'new' $TargetSha $Route
}
finally {
Stop-DesktopRecorder $recorder (Join-Path $proof "desktop-frames")
$handoffLog = Join-Path $HermesHome "logs\desktop-update-handoff.log"
if (Test-Path -LiteralPath $handoffLog) {
Write-Host "::group::desktop-update-handoff.log"
Get-Content -LiteralPath $handoffLog | Write-Host
Write-Host "::endgroup::"
Copy-Item $handoffLog (Join-Path $proof "desktop-update-handoff.log") -Force -ErrorAction SilentlyContinue
}
# Quit the relaunched app so job teardown is clean.
Stop-HermesAppProcesses "post-update"
}
}
# --- plugin upgrade-preservation hooks -------------------------------------
# A tagged upgrade must not delete or modify anything under the active
# home's plugins/** or any profile's plugins/** tree: wrapper markers
# (mnemosyne-wrapper.json), symlinked runtimes, and the externally-owned
# sidecar witness outside the home. Fixtures are directory-only (no
# pyproject in the scanned root, nothing downloaded). Snapshot is taken
# after install, verified after update.
function Seed-PreservationFixtures {
$external = Join-Path $WorkRoot "external-mnemosyne-runtime"
& $DriverPython (Join-Path $AssetsDir "verify-plugin-preservation.py") seed --home $HermesHome --external $external
if ($LASTEXITCODE -ne 0) { throw "could not seed fresh preservation fixtures (exit $LASTEXITCODE)" }
}
function Invoke-PreserveSnapshot {
$out = Join-Path $WorkRoot "plugin-preservation-snapshot.json"
if (Test-Path -LiteralPath $out) { throw "refusing to overwrite an existing preservation snapshot" }
Seed-PreservationFixtures
& $DriverPython (Join-Path $AssetsDir "verify-plugin-preservation.py") snapshot --home $HermesHome --out $out
if ($LASTEXITCODE -ne 0) { throw "plugin preservation snapshot failed (exit $LASTEXITCODE)" }
Write-Host " pre-upgrade plugin snapshot: $out"
}
function Invoke-PreserveVerify {
$snap = Join-Path $WorkRoot "plugin-preservation-snapshot.json"
if (-not (Test-Path -LiteralPath $snap)) { throw "no pre-upgrade plugin snapshot at $snap; cannot verify preservation" }
& $DriverPython (Join-Path $AssetsDir "verify-plugin-preservation.py") verify --home $HermesHome --snapshot $snap `
--report (Join-Path $WorkRoot "logs\plugin-preservation-report.json")
if ($LASTEXITCODE -ne 0) { throw "plugin preservation violated by the upgrade (exit $LASTEXITCODE); see the report for deleted/modified entries" }
Write-Host " plugins/** and profile plugin trees survived the upgrade intact"
}
# ----------------------------------------------------------------------------
# User-state preservation: the user's OWN durable state, produced through the
# ordinary CLI (never seeded by us), snapshotted before the upgrade and
# verified after it. Complements the plugin-tree contract above, which owns
# plugins/** only.
# ----------------------------------------------------------------------------
function Get-UserStateSessionCount {
# A real chat turn must actually create a session; if it silently does not,
# the preservation check below would be testing nothing.
$probe = Join-Path $WorkRoot 'user-state-session-count.py'
if (-not (Test-Path -LiteralPath $probe)) {
@'
import sqlite3, sys
try:
con = sqlite3.connect("file:" + sys.argv[1].replace("\\", "/") + "?mode=ro", uri=True)
print(con.execute("SELECT COUNT(*) FROM sessions").fetchone()[0])
except Exception:
print(-1)
'@ | Set-Content -LiteralPath $probe -Encoding ASCII
}
$prevEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
try { $value = (& $DriverPython $probe (Join-Path $HermesHome 'state.db') 2>$null | Out-String).Trim() }
finally { $ErrorActionPreference = $prevEap }
if ($value -match '^-?\d+$') { return [int]$value }
return -1
}
function Invoke-UserStateActions {
# Everything here is a command a user would run against the real installed
# CLI with a real (mocked-inference) provider configured.
$hermes = Get-SourceHermes $InstallDir
if (-not $script:ChatMock) {
# Same mock + config writer the desktop chat checkpoints use, so the
# leg has a genuinely configured provider rather than a dummy key.
$script:ChatMock = Start-DesktopJourneyMock $DriverNode $AssetsDir $WorkRoot $HermesHome $ProofRoot
}
$prevLazy = $env:HERMES_DISABLE_LAZY_INSTALLS
$prevEap = $ErrorActionPreference
try {
$env:HERMES_DISABLE_LAZY_INSTALLS = '1'
$ErrorActionPreference = 'Continue'
# Probe, do not assume (the harness rule for old refs).
$chatHelp = (& $hermes chat --help 2>&1 | Out-String)
if (-not ($chatHelp -match '(^|\s)-q(\s|,|$)' -or $chatHelp -match '--quiet')) {
throw 'the installed CLI has no one-shot chat flag; this leg cannot produce a session through the user path'
}
# Since a5c7eed (v2026.9.21+) `-q` on a real TTY seeds an INTERACTIVE session and
# only answers-and-exits with --oneshot (or on non-TTY stdio). The pseudoconsole
# below IS a TTY, so without --oneshot the turn answers and then sits at the
# prompt forever. Older tags have no --oneshot and exit after -q on their own.
$oneshot = @()
if ($chatHelp -match '--oneshot') { $oneshot = @('--oneshot') }
$before = Get-UserStateSessionCount
$log = Join-Path $WorkRoot 'logs\user-state-chat.log'
# A released tag prints through prompt_toolkit, whose Windows output object needs
# a console screen buffer: piping the CLI's stdout into the log takes that away
# and the turn dies with NoConsoleScreenBufferError. Run it under a real
# pseudoconsole (pty-run.py) and keep the capture.
& $DriverPython -B (Join-Path $AssetsDir 'pty-run.py') --out $log --timeout 300 -- $hermes chat -q "Reply with the single word: ok" @oneshot
$chatExit = $LASTEXITCODE
Write-LogGroup 'first real chat turn' $log
if ($chatExit -eq 124) {
throw "the first chat turn never exited (still running after 300s; flags: chat -q $($oneshot -join ' ')); see $log"
}
if ($chatExit -ne 0) { throw "the first chat turn failed (exit $chatExit); see $log" }
$after = Get-UserStateSessionCount
# A fresh install has no state.db until the first turn: -1 means "no
# readable db yet", the expected starting point, not a failure.
if ($before -lt 0) { $before = 0 }
if ($after -lt 0) { $after = 0 }
if (-not ($after -gt $before)) {
throw "the chat turn produced no session row (state.db sessions $before -> $after)"
}
Write-Host " a real turn created a session (state.db sessions $before -> $after)"
if (-not (Test-Path -LiteralPath (Join-Path $HermesHome 'auth.json'))) {
# A starting tag may not have this subcommand yet: a harness
# limitation, not a preservation failure.
& $hermes auth add --help *> $null
if ($LASTEXITCODE -ne 0) {
Write-Host ' SKIP hermes auth add does not exist on this ref; auth.json is not covered by this leg'
}
else {
# The provider id and the flags are vintage surfaces, so probe them
# like the rest of this harness does. v2026.8.31 answers "Unknown
# provider: openai" -- _is_known_provider accepts a registry provider,
# 'openrouter', or a custom pool -- and HEAD prompts for an optional
# label unless --label is given, which EOFs with no tty. Take the
# first provider the installed CLI accepts.
$authLog = Join-Path $WorkRoot 'logs\user-state-auth.log'
$labelFlags = @()
if ((& $hermes auth add --help 2>&1 | Out-String) -match '--label') {
$labelFlags = @('--label', 'e2e-preservation')
}
$added = $false
foreach ($provider in @('openrouter', 'anthropic')) {
Add-Content -LiteralPath $authLog -Value "=== hermes auth add $provider ==="
& $hermes auth add $provider --type api-key `
--api-key 'e2e-preservation-not-a-real-key' @labelFlags 2>&1 |
Out-File -Encoding UTF8 -Append $authLog
if (Test-Path -LiteralPath (Join-Path $HermesHome 'auth.json')) {
$added = $true
break
}
}
if (-not $added) {
throw "hermes auth add failed for openrouter and anthropic; see $authLog"
}
Write-Host ' a pooled credential exists (auth.json)'
}
}
if (-not (Test-Path -LiteralPath (Join-Path $HermesHome 'profiles\e2e-second'))) {
# Same vintage surface as auth add above: a starting tag may predate
# the profile command entirely, and that is a harness limitation,
# not a preservation failure.
& $hermes profile create --help *> $null
if ($LASTEXITCODE -ne 0) {
Write-Host ' SKIP hermes profile create does not exist on this ref; profiles/e2e-second is not covered by this leg'
}
else {
& $hermes profile create e2e-second 2>&1 |
Out-File -Encoding UTF8 (Join-Path $WorkRoot 'logs\user-state-profile.log')
if ($LASTEXITCODE -ne 0) { throw 'hermes profile create failed' }
if (-not (Test-Path -LiteralPath (Join-Path $HermesHome 'profiles\e2e-second'))) {
throw 'hermes profile create produced no profile dir'
}
# Factory templates migrate intentionally; preserve an authored profile instead.
Add-Content -LiteralPath (Join-Path $HermesHome 'profiles\e2e-second\SOUL.md') `
-Encoding UTF8 -Value "`nUser preference: preserve my e2e-second profile identity across upgrades."
Write-Host ' a second profile exists (profiles/e2e-second)'
}
}
}
finally {
$env:HERMES_DISABLE_LAZY_INSTALLS = $prevLazy
$ErrorActionPreference = $prevEap
}
}
function Invoke-UserStateSnapshot {
$snap = Join-Path $WorkRoot 'user-state-snapshot.json'
if (Test-Path -LiteralPath $snap) { throw 'refusing to overwrite an existing user-state snapshot' }
& $DriverPython (Join-Path $AssetsDir 'verify-user-state.py') snapshot --home $HermesHome --out $snap
if ($LASTEXITCODE -ne 0) { throw "user-state snapshot failed (exit $LASTEXITCODE)" }
Write-Host " pre-upgrade user-state snapshot: $snap"
}
function Invoke-UserStateVerify {
$snap = Join-Path $WorkRoot 'user-state-snapshot.json'
if (-not (Test-Path -LiteralPath $snap)) {
throw 'no pre-upgrade user-state snapshot; cannot claim preservation'
}
$report = Join-Path $WorkRoot 'logs\user-state-report.json'
$prevEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
try {
& $DriverPython (Join-Path $AssetsDir 'verify-user-state.py') verify --home $HermesHome `
--snapshot $snap --report $report
$code = $LASTEXITCODE
}
finally { $ErrorActionPreference = $prevEap }
if ($code -ne 0) {
throw "the upgrade changed the user's own state (exit $code); report at $report"
}
Write-Host " the user's own durable state survived the upgrade"
}
function Assert-RedirectIsTransportOnly {
# The redirect must stay at TRANSPORT level: `hermes update` resolves its
# channel from the release archive and validates the record against
# `git config --get remote.origin.url`. If the configured URL ever looked
# like the rehearsal source, channel resolution would fail and this leg
# would be testing a fork install rather than the real user path.
$official = @('https://github.com/NousResearch/hermes-agent.git',
'git@github.com:NousResearch/hermes-agent.git')
$configured = (Invoke-Git @('-C', $InstallDir, 'config', '--get', 'remote.origin.url') | Out-String).Trim()
Assert-True ($official -contains $configured) "origin stays configured as an official URL (got '$configured')"
$real = if ($env:HERMES_E2E_REAL_GIT) { $env:HERMES_E2E_REAL_GIT } else { 'git' }
$observed = (& $real -C $InstallDir remote get-url origin 2>$null | Out-String).Trim()
Assert-True ($observed -match 'serve\.git|^file://') "git transport is redirected to the staged repo (got '$observed')"
}
function Assert-UserShims {
# A launcher left pointing at a vanished tree is the "update lost
# something" shape a checkout-hash assertion cannot see.
$hermes = Get-SourceHermes $InstallDir
Assert-True (Test-Path -LiteralPath $hermes) "a usable launcher still exists after the upgrade ($hermes)"
$userShim = Join-Path $HermesHome 'bin\hermes.exe'
if (-not (Test-Path -LiteralPath $userShim)) { $userShim = Join-Path $HermesHome 'bin\hermes.cmd' }
if (Test-Path -LiteralPath $userShim) {
$prevEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
try {
& $userShim --version 2>&1 | Out-Null
$shimExit = $LASTEXITCODE
}
finally { $ErrorActionPreference = $prevEap }
Assert-True ($shimExit -eq 0) "the $HermesHome\bin launcher still runs after the upgrade"
}
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath) {
# The fixture home contains ``..`` while Windows can persist the same
# directory canonically. Compare path identities, not raw substrings.
$expectedUserBin = [IO.Path]::GetFullPath((Join-Path $HermesHome 'bin')).TrimEnd('\')
$userPathEntries = @(
foreach ($entry in ($userPath -split ';')) {
if (-not $entry) { continue }
$expanded = [Environment]::ExpandEnvironmentVariables($entry)
try { [IO.Path]::GetFullPath($expanded).TrimEnd('\') }
catch { $expanded.TrimEnd('\') }
}
)
Assert-True ($userPathEntries -icontains $expectedUserBin) `
"the USER PATH still exposes $expectedUserBin (actual: $userPath)"
}
}
function Invoke-PhaseInstall {
# Dispatch on the install axis. Each arm ends with the same contract:
# checkout at OLD, hermes runs, and state carries how OLD landed so any
# update arm can follow any install arm.
$state = Read-State
$script:ExpectedDesktop = if ($InstallMethod -eq 'installer-script') { 'absent' } else { 'present' }
# Isolated install target for every arm; serve.git's file:// origin
# looks like a fork to the updater, whose "add the official repo as
# upstream?" prompt would hang a headless run - the marker is the
# product's own suppression mechanism.
$env:HERMES_HOME = $HermesHome
New-Item -ItemType Directory -Path $HermesHome -Force | Out-Null
switch ($InstallMethod) {
"desktop-installer@latest" {
Invoke-PhaseInstallGui
}
"installer-script" {
Write-Step "INSTALL (script): OLD's own install.ps1, headless"
Invoke-RefInstaller $state.old "old"
Assert-True ((Get-InstalledHead) -eq $state.old) "installed checkout is at OLD"
Test-HermesRuns "post-install-script"
}
"installer-script+desktop" {
Write-Step "INSTALL (script+desktop): OLD's own install.ps1 -IncludeDesktop, headless"
Invoke-RefInstaller $state.old "old" -IncludeDesktop
Assert-True ((Get-InstalledHead) -eq $state.old) "installed checkout is at OLD"
Test-HermesRuns "post-install-script-desktop"
Assert-DesktopArtifact "OLD"
}
}
if ($InstallMethod -ne 'desktop-installer@latest') {
Invoke-DesktopCheckpoint 'old' $state.old $InstallMethod
}
Assert-RedirectIsTransportOnly
Invoke-UserStateActions
}
function Invoke-PhaseUpdate {
$state = Read-State
$script:ExpectedDesktop = if ($InstallMethod -ne 'installer-script' -or $Route -in @(
'installer-script+desktop', 'desktop-installer@latest', 'open-app-update', 'hermes-desktop-app-update'
)) { 'present' } else { 'absent' }
$env:HERMES_HOME = $HermesHome
# Match the POSIX driver's explicit opt-out when a detached updater bypasses
# the PATH shim and sees our local transport as a fork.
New-Item -ItemType File -Path (Join-Path $HermesHome ".skip_upstream_prompt") -Force | Out-Null
# The update becomes available the way it does for a real user: the
# remote's main moves forward. The GUI route re-advances harmlessly
# (same sha); script routes need it here because only the GUI arm's
# helper used to own this step.
# The mock provider is journey setup, not an upgrade mutation. Configure it
# before preservation snapshots so its stable endpoint is part of baseline state.
if ($Route -in @('open-app-update', 'hermes-desktop-app-update', 'desktop-installer@latest')) {
Start-JourneyChat
}
# Snapshot every plugin tree BEFORE the upgrade moves anything.
Invoke-PreserveSnapshot
# ... and the user's own durable state, produced by the install phase
# through the ordinary CLI.
Invoke-UserStateSnapshot
Invoke-Git @("-C", $ServeRepo, "update-ref", "refs/heads/main", $state.current) | Out-Null
Write-Host " serve.git main advanced to $($state.current)"
switch ($Route) {
"open-app-update" {
# Meaningful only where an OS entry point exists - install.ps1
# -IncludeDesktop registers shortcuts too, so both desktop-
# bearing installs qualify; the workflow gate enforces which
# pairs are dispatched.
Invoke-GuiUpdateDesktopRoute $state.current
}
"hermes-desktop-app-update" {
Invoke-HermesDesktopAppUpdate $state.current
}
"hermes-update" {
Invoke-HermesUpdate
}
"installer-script" {
# A user re-running the one-liner today gets the CURRENT script.
Invoke-RefInstaller $state.current "head"
}
"installer-script+desktop" {
Invoke-RefInstaller $state.current "head" -IncludeDesktop
Assert-DesktopArtifact "HEAD"
}
"desktop-installer@latest" {
# A user re-downloading Hermes-Setup.exe and clicking Install over
# the existing install (the GUI twin of re-running the one-liner).
# Windows has no already-installed fast path, so the full installer
# UI shows and the same AHK drive applies; install.ps1's repository
# stage fetches into the existing checkout, now aimed at HEAD.
# Rotate the bootstrap log first: it appends across runs, and the
# AHK's "bootstrap complete" fallback must not match the install
# phase's completion line.
$bootLog = Join-Path $HermesHome "logs\bootstrap-installer.log"
if (Test-Path -LiteralPath $bootLog) {
Move-Item -LiteralPath $bootLog -Destination "$bootLog.install-phase" -Force
}
Invoke-PhaseInstallGui -Mode "update" -ExpectedSha $state.current -ExpectedLabel $state.target_label
Assert-DesktopArtifact "HEAD"
}
}
Assert-True ((Get-InstalledHead) -eq $state.current) "checkout landed on $($state.target_label)"
Test-HermesRuns "post-update"
Assert-UserShims
Invoke-PreserveVerify
Invoke-UserStateVerify
if ($Route -notin @('open-app-update', 'desktop-installer@latest')) {
Invoke-DesktopCheckpoint 'new' $state.current $Route
}
}
function Invoke-CheckedPhaseUpdate {
Remove-Item -LiteralPath (Join-Path $WorkRoot "known-failure.json") -Force -ErrorAction SilentlyContinue
# Only evidence produced by this update attempt can match an exception.
foreach ($oldLog in @((Join-Path $WorkRoot "logs\update.log"), (Join-Path $HermesHome "logs\desktop.log"))) {
if (Test-Path -LiteralPath $oldLog) { Move-Item -LiteralPath $oldLog -Destination "$oldLog.before-update" -Force }
}
try {
Invoke-PhaseUpdate
} catch {
$failure = $_
if ($script:ChatFailure) { throw $failure }
$node = $DriverNode
$classification = & $node (Join-Path $AssetsDir "known-failures.cjs") $WorkRoot $InstallMethod $Route $failure.Exception.Message
$classificationExit = $LASTEXITCODE
if ($classificationExit -ne 0) { throw $failure }
$receipt = ($classification | Out-String) | ConvertFrom-Json
Write-Host "KNOWN FAILURE [$($receipt.id)]: $($receipt.title)"
Write-Host " $($receipt.explanation)"
if ($env:GITHUB_OUTPUT) {
Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value "known_failure=$($receipt.id)" -Encoding UTF8
}
if ($env:GITHUB_STEP_SUMMARY) {
Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Encoding UTF8 -Value "Known historical failure: $($receipt.title). See the result chart footnote and uploaded known-failure.json."
}
}
}
function Invoke-PhaseVerifyStamp {
# Runs AFTER everything (install, update, the new runtime's launch and its
# smoke checks): the bootstrap-complete receipt and the checkout's source
# stamp must both tell the truth about the final HEAD. A separate phase —
# not an install-phase check — because the bootstrap marker can complete
# on a LATER run than the install itself.
$state = Read-State
$head = Get-InstalledHead
Assert-True ($head -match '^[0-9a-f]{40}$') "installed HEAD readable: '$head'"
Write-Host " install HEAD: $($head.Substring(0, 12))"
$stampPath = Join-Path $InstallDir '.hermes-bootstrap-complete'
# FAIL lines go to stderr; under "Stop", PowerShell 5.1 would throw on the first one.
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$verdict = @(& $DriverPython -B (Join-Path $RepoRoot 'scripts\verify-bootstrap-version-stamp.py') `
--stamp $stampPath --repo $InstallDir --expect-commit $state.current 2>&1 | ForEach-Object { "$_" })
$verifyExit = $LASTEXITCODE
} finally { $ErrorActionPreference = $prevEap }
$verdict | ForEach-Object { Write-Host $_ }
if ($verifyExit -eq 0) { return }
# Permanent legacy shape, not a pending fix: the released Hermes-Setup.exe
# replaces install.ps1's receipt with its own (#124949): "completedAtUnix"
# (epoch seconds) instead of "completedAt", and a null pinnedCommit when git
# is not on PATH. #125053 fixed the writer, but the installer .exe is not
# rebuilt, so every Desktop-installer machine carries this receipt until the
# next `hermes update` rewrites it (hermes_cli/source_stamp.py). No reader
# depends on those two fields: Desktop's launch gate is runtime usability,
# and the receipt's other readers check only that it exists. So the contract
# for that shape is: exactly those two FAIL lines, an integer
# completedAtUnix, and the checkout itself at the expected commit. Any other
# FAIL line, or the same lines on a receipt without completedAtUnix, stays red.
$receipt = Get-Content -LiteralPath $stampPath -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json -ErrorAction SilentlyContinue
$fails = @($verdict | Where-Object { $_ -match '^FAIL: ' })
$legacyOnly = '^FAIL: (pinnedCommit None is not a full 40-char lowercase hex sha|completedAt None is missing)$'
$setupExeReceipt = $receipt -and ($receipt.PSObject.Properties.Name -contains 'completedAtUnix')
if ($setupExeReceipt -and $fails.Count -and -not @($fails | Where-Object { $_ -notmatch $legacyOnly }).Count) {
$unix = $receipt.completedAtUnix
Assert-True ((($unix -is [int]) -or ($unix -is [long])) -and $unix -gt 0) "Hermes-Setup.exe receipt completedAtUnix is epoch seconds: '$unix'"
# The receipt cannot vouch for the commit, so the checkout must.
Assert-True ($head -eq $state.current) "installed checkout is at the expected commit ($($state.current.Substring(0, 12)))"
$note = "Hermes-Setup.exe legacy receipt accepted (#124949; the released exe is not rebuilt): $($fails -join '; ')"
Write-Host " $note"
if ($env:GITHUB_STEP_SUMMARY) { Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value $note -Encoding UTF8 }
return
}
throw "stamp verification failed (exit $verifyExit)"
}
# ----------------------------------------------------------------------------
# Dispatch
# ----------------------------------------------------------------------------
Write-Host "Windows install/update E2E driver (real user flows)"
Write-Host " phase: $Phase"
Write-Host " install: $InstallMethod"
Write-Host " route: $Route"
Write-Host " repo: $RepoRoot"
Write-Host " workroot: $WorkRoot"
$script:RealGitExe = (Get-Command git.exe -ErrorAction Stop).Source
# The HEAD start is the fresh-machine leg: HEAD's installer on a box with
# nothing on it, git included (see Set-GitRedirect).
$script:FreshMachine = ($InstallRef -eq "HEAD")
Set-GitRedirect
try {
switch ($Phase) {
"stage" { Invoke-PhaseStage }
"install" { Invoke-SourceBuild { Invoke-PhaseInstall } }
"update" { Invoke-SourceBuild { Invoke-CheckedPhaseUpdate } }
"verify-stamp" { Invoke-PhaseVerifyStamp }
"all" {
Invoke-PhaseStage
Invoke-SourceBuild { Invoke-PhaseInstall }
Invoke-SourceBuild { Invoke-CheckedPhaseUpdate }
Invoke-PhaseVerifyStamp
}
}
} finally {
if ($script:ChatMock -and -not $script:ChatMock.HasExited) {
Stop-Process -Id $script:ChatMock.Id -ErrorAction SilentlyContinue
}
}
Write-Host ""
Write-Host "Phase '$Phase' completed successfully."