mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
feat(install.ps1): stage protocol + Windows clean-VM hardening pass
Adds an opt-in stage protocol that lets programmatic drivers (the
desktop GUI's onboarding wizard, CI, future install.sh parity) drive
install.ps1 one step at a time with structured JSON results. Default
invocation (`irm | iex` one-liner) behaves unchanged.
Entry points:
install.ps1 Today's interactive install (unchanged)
install.ps1 -ProtocolVersion Emit protocol version integer
install.ps1 -Manifest Emit JSON manifest of available stages
install.ps1 -Stage <name> Run one stage, emit JSON result
install.ps1 -NonInteractive Suppress Read-Host prompts (skips the
setup wizard and gateway autostart)
install.ps1 -Json Machine-readable completion frame
Manifest exposes 14 stages across prereqs/install/finalize/post-install
categories, with 2 (configure, gateway) flagged needs_user_input=true
so GUI drivers can skip them and handle the equivalent UX themselves.
Along the way, clean-VM testing on stock Windows 10/11 surfaced a
series of latent install.ps1 bugs that were never exercised by
developer machines. Fixed in the same commit:
* Encoding: file is now pure ASCII with no BOM. Windows PowerShell
5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes
(and other UTF-8 sequences), while iex chokes on a leading U+FEFF.
Pure-ASCII satisfies both invocation paths.
* EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines
from native commands as ErrorRecord objects under EAP=Stop and
throws even when the command exits 0. Relaxed to EAP=Continue
around the astral.sh uv installer, `uv python install`, `npm
install`, `npx playwright install`, the venv import probes, and
the Node winget fallback. Check $LASTEXITCODE for the real signal.
* Cross-process state: each `-Stage <name>` invocation spawns a
fresh powershell child. $script:UvCmd set by Stage-Uv was invisible
to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible
to subsequent stages spawned by the driver shell. Added Resolve-UvCmd
helper called at the top of every stage that needs uv, and a
Sync-EnvPath helper called at the top of Invoke-Stage to refresh
PATH from the registry.
* UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC
prompt that often appears minimized in the taskbar -- looks like a
hang. Switched Test-Node to prefer the official portable Node zip
dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit
pattern Install-Git already uses). winget kept as fallback.
* npx hangs on confirmation: `npx playwright install chromium` blocks
on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when
playwright isn't in local node_modules. Tee-Object pipelines
disconnect stdin from the user's TTY so the install hangs forever.
Pass `--yes` to auto-accept.
* Silent long-running installs: `*> $logPath` redirected every stream
to disk and left the user staring at a frozen "Installing..." line
for the 5-10 minutes Playwright Chromium takes to download. Switched
to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so
output streams live to the console AND captures to log for failure
diagnostics. ForEach-Object coercion strips PowerShell's red
NativeCommandError formatter from stderr items.
* Console encoding: forced [Console]::OutputEncoding to UTF-8 so
playwright/git/npm progress bars, box-drawing, and check marks render
correctly instead of as IBM437/Windows-1252 mojibake.
* Performance: set $ProgressPreference = "SilentlyContinue" so
Invoke-WebRequest doesn't paint its per-chunk progress bar. The
PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit
grab takes 5 minutes with the bar on vs ~20 seconds with it off,
same network). Affects PortableGit, Node portable zip, and the
Hermes repo zip fallback.
Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19
metadata-only assertions covering -ProtocolVersion, -Manifest schema,
and unknown -Stage error frame. No install side effects.
End-to-end validated on a clean Windows 10 VM via:
1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path)
2. `powershell -File install.ps1 -Stage X` iterated through every
stage (GUI driver path, exercises cross-process fixes)
This commit is contained in:
parent
ea2ee51f0b
commit
e5f19af2a5
2 changed files with 736 additions and 130 deletions
File diff suppressed because it is too large
Load diff
134
scripts/tests/test-install-ps1-stage-protocol.ps1
Normal file
134
scripts/tests/test-install-ps1-stage-protocol.ps1
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Smoke tests for the install.ps1 stage protocol.
|
||||
#
|
||||
# Run from a PowerShell prompt:
|
||||
#
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-stage-protocol.ps1
|
||||
#
|
||||
# These tests only exercise the metadata surface (-ProtocolVersion, -Manifest,
|
||||
# unknown -Stage handling). They DO NOT actually run any install stages —
|
||||
# those have heavy side effects (winget, git clone, pip install, PATH writes)
|
||||
# and are out of scope for a unit smoke test. All three metadata commands
|
||||
# below return without invoking Main / Invoke-AllStages.
|
||||
#
|
||||
# To exercise real install stages, drive the script from a clean VM.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
|
||||
$installScript = Join-Path $repoRoot "scripts\install.ps1"
|
||||
|
||||
if (-not (Test-Path $installScript)) {
|
||||
throw "Could not locate install.ps1 at $installScript"
|
||||
}
|
||||
|
||||
$failures = 0
|
||||
function Assert-Equal {
|
||||
param([Parameter(Mandatory=$true)] $Expected,
|
||||
[Parameter(Mandatory=$true)] $Actual,
|
||||
[Parameter(Mandatory=$true)] [string]$Label)
|
||||
if ($Expected -ne $Actual) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
Write-Host " expected: $Expected"
|
||||
Write-Host " actual: $Actual"
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
function Assert-True {
|
||||
param([Parameter(Mandatory=$true)] $Condition,
|
||||
[Parameter(Mandatory=$true)] [string]$Label)
|
||||
if (-not $Condition) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: -ProtocolVersion emits a single integer
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -ProtocolVersion --"
|
||||
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -ProtocolVersion
|
||||
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-ProtocolVersion exits 0"
|
||||
Assert-True ($output -match '^\d+$') -Label "-ProtocolVersion emits an integer (got: $output)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: -Manifest emits valid JSON with expected shape
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -Manifest --"
|
||||
$manifestJson = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Manifest
|
||||
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-Manifest exits 0"
|
||||
|
||||
$manifest = $null
|
||||
try {
|
||||
$manifest = $manifestJson | ConvertFrom-Json
|
||||
Assert-True $true -Label "-Manifest output parses as JSON"
|
||||
} catch {
|
||||
Assert-True $false -Label "-Manifest output parses as JSON (parse error: $_)"
|
||||
}
|
||||
|
||||
if ($manifest) {
|
||||
Assert-True ($manifest.protocol_version -is [int] -or $manifest.protocol_version -is [long]) `
|
||||
-Label "manifest.protocol_version is an integer"
|
||||
Assert-True ($manifest.stages.Count -gt 0) -Label "manifest.stages is non-empty"
|
||||
|
||||
# Every stage has the four required fields
|
||||
$allValid = $true
|
||||
foreach ($stage in $manifest.stages) {
|
||||
foreach ($field in @("name", "title", "category", "needs_user_input")) {
|
||||
if (-not ($stage.PSObject.Properties.Name -contains $field)) {
|
||||
Write-Host " stage missing field '$field': $($stage | ConvertTo-Json -Compress)" -ForegroundColor Red
|
||||
$allValid = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert-True $allValid -Label "every stage has name/title/category/needs_user_input"
|
||||
|
||||
# Specific stage names that the GUI driver will rely on
|
||||
$names = $manifest.stages | ForEach-Object { $_.name }
|
||||
foreach ($expected in @("uv", "python", "git", "venv", "dependencies", "configure", "gateway")) {
|
||||
Assert-True ($names -contains $expected) -Label "manifest contains stage '$expected'"
|
||||
}
|
||||
|
||||
# The two known-interactive stages must declare needs_user_input
|
||||
$interactive = $manifest.stages | Where-Object { $_.needs_user_input } | ForEach-Object { $_.name }
|
||||
Assert-True ($interactive -contains "configure") -Label "'configure' stage flagged needs_user_input"
|
||||
Assert-True ($interactive -contains "gateway") -Label "'gateway' stage flagged needs_user_input"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: unknown stage name -> exit 2, structured JSON error
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -Stage with unknown name --"
|
||||
$errOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Stage "does-not-exist"
|
||||
Assert-Equal -Expected 2 -Actual $LASTEXITCODE -Label "unknown -Stage exits 2"
|
||||
|
||||
$errFrame = $null
|
||||
try {
|
||||
$errFrame = $errOutput | ConvertFrom-Json
|
||||
Assert-True $true -Label "unknown-stage output parses as JSON"
|
||||
} catch {
|
||||
Assert-True $false -Label "unknown-stage output parses as JSON (parse error: $_)"
|
||||
}
|
||||
|
||||
if ($errFrame) {
|
||||
Assert-Equal -Expected $false -Actual $errFrame.ok -Label "unknown-stage frame has ok=false"
|
||||
Assert-Equal -Expected "does-not-exist" -Actual $errFrame.stage -Label "unknown-stage frame echoes stage name"
|
||||
Assert-True ($errFrame.reason -match "unknown stage") -Label "unknown-stage frame explains why"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Summary
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
if ($failures -gt 0) {
|
||||
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "All smoke tests passed." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue