convert install.ps1 to new bootstrap protocol - #27224
Conversation
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)
60c881e to
5165819
Compare
There was a problem hiding this comment.
Pull request overview
Adds an opt-in "stage protocol" to scripts/install.ps1 so programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh) can drive the Windows installer one step at a time and receive structured JSON results, while the default interactive irm | iex flow is unchanged. The PR also replaces non-ASCII glyphs in console output with ASCII equivalents (for PS 5.1 parser/codepage robustness), forces UTF-8 console output for child commands, silences Invoke-WebRequest progress bars, demotes the winget Node install behind the portable-zip path, and adds several $ErrorActionPreference relaxations around native commands whose stderr would otherwise be wrapped as terminating errors.
Changes:
- New stage protocol surface in
install.ps1:-ProtocolVersion,-Manifest,-Stage <name>,-NonInteractive,-Json, plus a 14-stage$InstallStagestable, per-stage workers,Invoke-Stage/Invoke-AllStages, andResolve-UvCmd/Sync-EnvPathhelpers for cross-process driver mode. - Operational hardening: UTF-8 console encoding,
$ProgressPreference = SilentlyContinue, EAP relaxations arounduv,winget,python -c,npm install, andplaywright install, Tee-Object live output for npm/playwright, persisted User-PATH entry for the portable Node install. - New PowerShell smoke test (
scripts/tests/test-install-ps1-stage-protocol.ps1) covering-ProtocolVersion,-Manifestshape and required stage names, and unknown-stage error framing.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| scripts/install.ps1 | Adds the stage-protocol parameters, dispatch block, stage table/workers, helper functions, and various EAP/UTF-8/Tee operational changes; replaces non-ASCII banner/log glyphs with ASCII. |
| scripts/tests/test-install-ps1-stage-protocol.ps1 | New metadata-only smoke test that runs -ProtocolVersion, -Manifest, and an unknown -Stage and asserts exit codes plus JSON shape. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } catch { | ||
| $result.ok = $false | ||
| $result.reason = "$_" | ||
| throw | ||
| } finally { | ||
| $result.duration_ms = [int]([DateTime]::UtcNow - $start).TotalMilliseconds | ||
| if ($Json -or $Stage) { | ||
| # In stage-driver mode every stage emits a JSON line so the | ||
| # caller can stream progress. In default interactive mode we | ||
| # stay silent here (the worker already wrote human output). | ||
| $result | ConvertTo-Json -Compress | Write-Output | ||
| } | ||
| } |
| if ($Json -or $Stage) { | ||
| # Stage-driver mode: caller wants JSON they can parse. Emit a | ||
| # structured error frame and exit non-zero. | ||
| $err = @{ | ||
| ok = $false | ||
| stage = if ($Stage) { $Stage } else { $null } | ||
| reason = "$_" | ||
| } | ||
| $err | ConvertTo-Json -Compress | Write-Output | ||
| exit 1 | ||
| } | ||
|
|
| # Restore EAP in case the try block threw before the assignment | ||
| if ($prevEAP) { $ErrorActionPreference = $prevEAP } | ||
| Write-Err "Failed to install uv: $_" |
alt-glitch
left a comment
There was a problem hiding this comment.
Inline comments on the two bugs found during Windows E2E testing.
Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot #1, #2). When -Stage <name> ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot #3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass.
alt-glitch
left a comment
There was a problem hiding this comment.
🐛 Request Changes: Double JSON on failed stages
Tested on Windows 11 PS 5.1.26100 via SSH. The stage protocol metadata surface is clean (19/19 smoke tests pass, cross-process driver works for uv/python/git/node), but there's a protocol contract bug that will bite programmatic drivers:
When a stage fails, the caller gets two JSON lines instead of one:
{"skipped":false,"ok":false,"reason":"Cannot find path ...","duration_ms":46,"stage":"venv"}
{"ok":false,"reason":"Cannot find path ...","stage":"venv"}
Root cause: Invoke-Stage emits JSON in its finally block (line 1987), then re-throws (line 1980). The outer catch at line 2062 also emits a JSON error frame. Both fire for the same failure.
Suggested fix — suppress the re-throw when running a single stage, since the JSON frame from finally is already the structured result:
# In Invoke-Stage, line 1980:
} catch {
$result.ok = $false
$result.reason = "$_"
if (-not $Stage) { throw } # Only re-throw in full-install mode
} finally {The full-install path (Main → Invoke-AllStages) still needs the re-throw so the outer catch can surface the error to the user. Single-stage mode (-Stage venv) doesn't — the JSON frame with ok=false is the contract, and the exit code from the finally→catch chain already propagates correctly.
Also two cosmetic nits (not blocking):
- Completion banner line 1756 is 62 chars wide vs 59-char borders (
[OK]is 3 wider than✓, trailing spaces not adjusted) - Test file line 8 has an em-dash (
—) — only non-ASCII byte across both files
alt-glitch
left a comment
There was a problem hiding this comment.
Adversarial review round 1 — two new bugs found (in addition to the double-JSON already flagged).
alt-glitch
left a comment
There was a problem hiding this comment.
Review — PR #27224: install.ps1 stage protocol + Windows clean-VM hardening
Tested on real Windows 11 PS 5.1.26100 via SSH (sidbin@vespyr), plus two parallel adversarial Claude Code Sonnet reviewers with independent focus areas.
Testing performed
| Test | Result |
|---|---|
| Smoke tests (19 assertions: -ProtocolVersion, -Manifest schema, unknown -Stage) | Pass |
| Cross-process stage driver (uv/python/git/node as separate PS processes) | Pass |
-NonInteractive suppresses Read-Host (configure stage returns immediately) |
Pass |
| Encoding: pure ASCII, no BOM (main had 1,409 non-ASCII bytes) | Pass |
Existing pytest suite (test_windows_native_support.py, 58 tests) |
Pass |
| Failed stage JSON framing | Bug |
| Stage-Node success reporting | Bug |
Empty -Stage "" dispatch |
Bug |
What's good
The stage protocol design is clean — single source of truth, thin stage workers, Resolve-UvCmd + Sync-EnvPath for cross-process state. The hardening fixes (EAP guards, ProgressPreference 10-100x speedup, portable Node over winget UAC, npx --yes, Tee-Object streaming, console encoding) are all correct and well-documented. The commit message is excellent — every fix traced to a specific clean-VM failure.
Bugs — see inline comments
- Double JSON on failed stages (HIGH) — line 1980
- Stage-Node reports
ok=truewhen Node install fails (MEDIUM) — line 1916 -Stage ""runs full install instead of erroring (MEDIUM) — line 2043- Completion banner misalignment (LOW) — line 1756
Adversarial findings rejected (verified false on PS 5.1)
ConvertTo-Json -Compressmulti-line → single-line confirmedMove-Itemfails with spaces → works fine- PATH duplication in Sync-EnvPath → cosmetic, safe
- Concurrent stage PATH race → stages are sequential by design
Ready to ship once bugs 1-3 are addressed.
| } catch { | ||
| $result.ok = $false | ||
| $result.reason = "$_" | ||
| throw |
There was a problem hiding this comment.
Bug 1: Double JSON on failed stages.
Invoke-Stage emits JSON in the finally block (line 1987), then re-throws here. The outer catch at line 2062 also emits a JSON error frame. Drivers get two lines for one failure:
{"skipped":false,"ok":false,"reason":"...","duration_ms":46,"stage":"venv"}
{"ok":false,"reason":"...","stage":"venv"}
Reproduced on PS 5.1.26100 with -Stage venv (no repo at default path).
Fix — suppress re-throw in single-stage mode:
if (-not $Stage) { throw } # Only re-throw in full-install mode| function Stage-Uv { if (-not (Install-Uv)) { throw "uv installation failed" } } | ||
| function Stage-Python { Resolve-UvCmd; if (-not (Test-Python)) { throw "Python $PythonVersion not available" } } | ||
| function Stage-Git { if (-not (Install-Git)) { throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run" } } | ||
| function Stage-Node { [void](Test-Node) } |
There was a problem hiding this comment.
Bug 2: Stage-Node always reports ok=true even when Node install fails.
Test-Node returns $true unconditionally (line 684). Stage-Node does [void](Test-Node) — never throws. In stage-driver mode the JSON says ok=true when Node isn't actually installed.
The default-install path works because $script:HasNode gates downstream behavior, but the stage protocol contract lies to the GUI driver.
Fix — surface the failure:
function Stage-Node { [void](Test-Node); if (-not $script:HasNode) { throw "Node.js not available (optional — browser tools disabled)" } }Or if the GUI should treat this as a soft skip, set $result.skipped = $true instead of throwing.
| exit 0 | ||
| } | ||
|
|
||
| if ($Stage) { |
There was a problem hiding this comment.
Bug 3: -Stage "" silently runs a full install.
PS treats "" as falsy, so if ($Stage) is false and dispatch falls through to Main. A GUI driver passing empty string gets an entire interactive install instead of an error.
Verified on PS 5.1.26100: "" = falsy, " " = truthy.
Fix — use $PSBoundParameters instead of truthy test:
if ($PSBoundParameters.ContainsKey('Stage')) {
if ([string]::IsNullOrWhiteSpace($Stage)) {
@{ ok = $false; stage = $Stage; reason = "Stage name cannot be empty." } | ConvertTo-Json -Compress | Write-Output
exit 2
}
# ... existing dispatch
}| Write-Host "│ ✓ Installation Complete! │" -ForegroundColor Green | ||
| Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Green | ||
| Write-Host "+---------------------------------------------------------+" -ForegroundColor Green | ||
| Write-Host "| [OK] Installation Complete! |" -ForegroundColor Green |
There was a problem hiding this comment.
Nit: banner misalignment. [OK] is 3 chars wider than ✓ but trailing spaces weren't reduced. This line is 62 chars, borders are 59.
+---------------------------------------------------------+ (59)
| [OK] Installation Complete! | (62)
+---------------------------------------------------------+ (59)
Address the two cosmetic items from review: - Completion banner middle line was 62 chars vs 59-char top/bottom borders (replacing the 1-char checkmark with [OK] added width that wasn't reflected in the trailing whitespace). Drop 3 trailing spaces. - Smoke test file had a single em-dash in a comment -- the only non-ASCII byte across both files. Replace with -- for consistency with install.ps1's pure-ASCII goal.
🔎 Lint report:
|
Two protocol-correctness gaps from review:
1. Stage-Node used [void](Test-Node) which discarded Test-Node's return
value, so the JSON frame always reported ok=true even when Node
install fully failed. A GUI driver consuming the manifest couldn't
tell 'node ready' from 'node missing'. Wire a soft-skip channel
($script:_StageSkippedReason) that workers can populate to surface
'ran, but the thing it was supposed to set up is not available' as
skipped=true with a reason in the JSON, without aborting the install
(Node is optional -- browser tools degrade gracefully, matches
Write-Completion's existing 'Note: Node.js could not be installed'
behavior). Reset before each stage so a prior reason can't leak.
2. The -Stage dispatch used 'if ($Stage)' which is falsy for empty
string, so 'install.ps1 -Stage ""' fell through to Main and silently
kicked off a full destructive install. Switch to
PSBoundParameters.ContainsKey('Stage') so an explicit empty value
surfaces as unknown-stage exit 2 with a structured JSON frame, the
way every other bad stage name does.
Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot #1, #2). When -Stage <name> ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot #3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass.
… ops Three install.ps1 improvements pulled from the thin-installer work on bb/gui (PR NousResearch#27822) that benefit the canonical CLI install flow on main: 1. Strip UTF-8 BOM from scripts/install.ps1. The canonical 'irm <raw URL> | iex' install flow has been broken since commit 4279da4 re-introduced a UTF-8 BOM that PR NousResearch#27224 had explicitly stripped. PowerShell 5.1's 'irm' returns the response body as a string with the BOM surviving as a leading \ufeff character; 'iex' then evaluates that string and the parser chokes on the invisible character before param(), surfacing as a cascade of 'The assignment expression is not valid' errors at every param default value. File body is verified pure ASCII (no character above byte 127), so PS 5.1 with no BOM falls back to Windows-1252 decoding which is identical to ASCII for our content. Both install paths work: - 'irm ... | iex' (canonical one-liner) - 'powershell -File install.ps1' (programmatic / desktop bootstrap) 2. New -Commit and -Tag string params for reproducible pinning. Higher-precedence variants of -Branch. When set, the repository stage clones $Branch (fast partial fetch) and then 'git checkout's the exact ref. Precedence: Commit > Tag > Branch. Honoured by all three code paths: - Update path (existing valid checkout): fetch + checkout --detach <commit|tag> instead of checkout + pull. - Fresh clone: clone --branch $Branch, then post-clone 'git checkout --detach' to the requested ref. - ZIP fallback: pick archive URL for the most-specific ref (commit -> archive/<sha>.zip, tag -> archive/refs/tags/ <tag>.zip, else archive/refs/heads/<branch>.zip). Used by the Hermes desktop's first-launch bootstrap to pin the .exe to the exact commit it was built against, so the cloned Hermes Agent tree always matches what the .exe was tested with. Also enables release-bundle pinning (e.g. Microsoft Store builds pinning to a release tag) and CI reproducibility. 3. EAP=Continue wrap around the new pin-step git invocations. 'git fetch origin <commit>' writes the routine 'From <url>' info line to stderr. Under the script's global $ErrorActionPreference = 'Stop' that stderr line is wrapped as an ErrorRecord and terminates the script even though fetch+checkout actually succeed. Same EAP=Stop + native-stderr footgun we hit during the install.ps1 hardening pass in Install-Uv, Test-Python, _Run-NpmInstall. Wrap both the update-path fetch/checkout block AND the post-clone pin block in $ErrorActionPreference = 'Continue' (restored in finally). Real failures still caught by $LASTEXITCODE checks.
Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot NousResearch#1, NousResearch#2). When -Stage <name> ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot NousResearch#3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass.
… ops Three install.ps1 improvements pulled from the thin-installer work on bb/gui (PR NousResearch#27822) that benefit the canonical CLI install flow on main: 1. Strip UTF-8 BOM from scripts/install.ps1. The canonical 'irm <raw URL> | iex' install flow has been broken since commit 4279da4 re-introduced a UTF-8 BOM that PR NousResearch#27224 had explicitly stripped. PowerShell 5.1's 'irm' returns the response body as a string with the BOM surviving as a leading \ufeff character; 'iex' then evaluates that string and the parser chokes on the invisible character before param(), surfacing as a cascade of 'The assignment expression is not valid' errors at every param default value. File body is verified pure ASCII (no character above byte 127), so PS 5.1 with no BOM falls back to Windows-1252 decoding which is identical to ASCII for our content. Both install paths work: - 'irm ... | iex' (canonical one-liner) - 'powershell -File install.ps1' (programmatic / desktop bootstrap) 2. New -Commit and -Tag string params for reproducible pinning. Higher-precedence variants of -Branch. When set, the repository stage clones $Branch (fast partial fetch) and then 'git checkout's the exact ref. Precedence: Commit > Tag > Branch. Honoured by all three code paths: - Update path (existing valid checkout): fetch + checkout --detach <commit|tag> instead of checkout + pull. - Fresh clone: clone --branch $Branch, then post-clone 'git checkout --detach' to the requested ref. - ZIP fallback: pick archive URL for the most-specific ref (commit -> archive/<sha>.zip, tag -> archive/refs/tags/ <tag>.zip, else archive/refs/heads/<branch>.zip). Used by the Hermes desktop's first-launch bootstrap to pin the .exe to the exact commit it was built against, so the cloned Hermes Agent tree always matches what the .exe was tested with. Also enables release-bundle pinning (e.g. Microsoft Store builds pinning to a release tag) and CI reproducibility. 3. EAP=Continue wrap around the new pin-step git invocations. 'git fetch origin <commit>' writes the routine 'From <url>' info line to stderr. Under the script's global $ErrorActionPreference = 'Stop' that stderr line is wrapped as an ErrorRecord and terminates the script even though fetch+checkout actually succeed. Same EAP=Stop + native-stderr footgun we hit during the install.ps1 hardening pass in Install-Uv, Test-Python, _Run-NpmInstall. Wrap both the update-path fetch/checkout block AND the post-clone pin block in $ErrorActionPreference = 'Continue' (restored in finally). Real failures still caught by $LASTEXITCODE checks.
Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot #1, #2). When -Stage <name> ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot #3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass.
… ops Three install.ps1 improvements pulled from the thin-installer work on bb/gui (PR NousResearch#27822) that benefit the canonical CLI install flow on main: 1. Strip UTF-8 BOM from scripts/install.ps1. The canonical 'irm <raw URL> | iex' install flow has been broken since commit 4279da4 re-introduced a UTF-8 BOM that PR NousResearch#27224 had explicitly stripped. PowerShell 5.1's 'irm' returns the response body as a string with the BOM surviving as a leading \ufeff character; 'iex' then evaluates that string and the parser chokes on the invisible character before param(), surfacing as a cascade of 'The assignment expression is not valid' errors at every param default value. File body is verified pure ASCII (no character above byte 127), so PS 5.1 with no BOM falls back to Windows-1252 decoding which is identical to ASCII for our content. Both install paths work: - 'irm ... | iex' (canonical one-liner) - 'powershell -File install.ps1' (programmatic / desktop bootstrap) 2. New -Commit and -Tag string params for reproducible pinning. Higher-precedence variants of -Branch. When set, the repository stage clones $Branch (fast partial fetch) and then 'git checkout's the exact ref. Precedence: Commit > Tag > Branch. Honoured by all three code paths: - Update path (existing valid checkout): fetch + checkout --detach <commit|tag> instead of checkout + pull. - Fresh clone: clone --branch $Branch, then post-clone 'git checkout --detach' to the requested ref. - ZIP fallback: pick archive URL for the most-specific ref (commit -> archive/<sha>.zip, tag -> archive/refs/tags/ <tag>.zip, else archive/refs/heads/<branch>.zip). Used by the Hermes desktop's first-launch bootstrap to pin the .exe to the exact commit it was built against, so the cloned Hermes Agent tree always matches what the .exe was tested with. Also enables release-bundle pinning (e.g. Microsoft Store builds pinning to a release tag) and CI reproducibility. 3. EAP=Continue wrap around the new pin-step git invocations. 'git fetch origin <commit>' writes the routine 'From <url>' info line to stderr. Under the script's global $ErrorActionPreference = 'Stop' that stderr line is wrapped as an ErrorRecord and terminates the script even though fetch+checkout actually succeed. Same EAP=Stop + native-stderr footgun we hit during the install.ps1 hardening pass in Install-Uv, Test-Python, _Run-NpmInstall. Wrap both the update-path fetch/checkout block AND the post-clone pin block in $ErrorActionPreference = 'Continue' (restored in finally). Real failures still caught by $LASTEXITCODE checks.
Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot #1, #2). When -Stage <name> ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot #3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass.
… ops Three install.ps1 improvements pulled from the thin-installer work on bb/gui (PR #27822) that benefit the canonical CLI install flow on main: 1. Strip UTF-8 BOM from scripts/install.ps1. The canonical 'irm <raw URL> | iex' install flow has been broken since commit dec2556 re-introduced a UTF-8 BOM that PR #27224 had explicitly stripped. PowerShell 5.1's 'irm' returns the response body as a string with the BOM surviving as a leading \ufeff character; 'iex' then evaluates that string and the parser chokes on the invisible character before param(), surfacing as a cascade of 'The assignment expression is not valid' errors at every param default value. File body is verified pure ASCII (no character above byte 127), so PS 5.1 with no BOM falls back to Windows-1252 decoding which is identical to ASCII for our content. Both install paths work: - 'irm ... | iex' (canonical one-liner) - 'powershell -File install.ps1' (programmatic / desktop bootstrap) 2. New -Commit and -Tag string params for reproducible pinning. Higher-precedence variants of -Branch. When set, the repository stage clones $Branch (fast partial fetch) and then 'git checkout's the exact ref. Precedence: Commit > Tag > Branch. Honoured by all three code paths: - Update path (existing valid checkout): fetch + checkout --detach <commit|tag> instead of checkout + pull. - Fresh clone: clone --branch $Branch, then post-clone 'git checkout --detach' to the requested ref. - ZIP fallback: pick archive URL for the most-specific ref (commit -> archive/<sha>.zip, tag -> archive/refs/tags/ <tag>.zip, else archive/refs/heads/<branch>.zip). Used by the Hermes desktop's first-launch bootstrap to pin the .exe to the exact commit it was built against, so the cloned Hermes Agent tree always matches what the .exe was tested with. Also enables release-bundle pinning (e.g. Microsoft Store builds pinning to a release tag) and CI reproducibility. 3. EAP=Continue wrap around the new pin-step git invocations. 'git fetch origin <commit>' writes the routine 'From <url>' info line to stderr. Under the script's global $ErrorActionPreference = 'Stop' that stderr line is wrapped as an ErrorRecord and terminates the script even though fetch+checkout actually succeed. Same EAP=Stop + native-stderr footgun we hit during the install.ps1 hardening pass in Install-Uv, Test-Python, _Run-NpmInstall. Wrap both the update-path fetch/checkout block AND the post-clone pin block in $ErrorActionPreference = 'Continue' (restored in finally). Real failures still caught by $LASTEXITCODE checks.
Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot NousResearch#1, NousResearch#2). When -Stage <name> ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot NousResearch#3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass.
… ops Three install.ps1 improvements pulled from the thin-installer work on bb/gui (PR NousResearch#27822) that benefit the canonical CLI install flow on main: 1. Strip UTF-8 BOM from scripts/install.ps1. The canonical 'irm <raw URL> | iex' install flow has been broken since commit 2089537 re-introduced a UTF-8 BOM that PR NousResearch#27224 had explicitly stripped. PowerShell 5.1's 'irm' returns the response body as a string with the BOM surviving as a leading \ufeff character; 'iex' then evaluates that string and the parser chokes on the invisible character before param(), surfacing as a cascade of 'The assignment expression is not valid' errors at every param default value. File body is verified pure ASCII (no character above byte 127), so PS 5.1 with no BOM falls back to Windows-1252 decoding which is identical to ASCII for our content. Both install paths work: - 'irm ... | iex' (canonical one-liner) - 'powershell -File install.ps1' (programmatic / desktop bootstrap) 2. New -Commit and -Tag string params for reproducible pinning. Higher-precedence variants of -Branch. When set, the repository stage clones $Branch (fast partial fetch) and then 'git checkout's the exact ref. Precedence: Commit > Tag > Branch. Honoured by all three code paths: - Update path (existing valid checkout): fetch + checkout --detach <commit|tag> instead of checkout + pull. - Fresh clone: clone --branch $Branch, then post-clone 'git checkout --detach' to the requested ref. - ZIP fallback: pick archive URL for the most-specific ref (commit -> archive/<sha>.zip, tag -> archive/refs/tags/ <tag>.zip, else archive/refs/heads/<branch>.zip). Used by the Hermes desktop's first-launch bootstrap to pin the .exe to the exact commit it was built against, so the cloned Hermes Agent tree always matches what the .exe was tested with. Also enables release-bundle pinning (e.g. Microsoft Store builds pinning to a release tag) and CI reproducibility. 3. EAP=Continue wrap around the new pin-step git invocations. 'git fetch origin <commit>' writes the routine 'From <url>' info line to stderr. Under the script's global $ErrorActionPreference = 'Stop' that stderr line is wrapped as an ErrorRecord and terminates the script even though fetch+checkout actually succeed. Same EAP=Stop + native-stderr footgun we hit during the install.ps1 hardening pass in Install-Uv, Test-Python, _Run-NpmInstall. Wrap both the update-path fetch/checkout block AND the post-clone pin block in $ErrorActionPreference = 'Continue' (restored in finally). Real failures still caught by $LASTEXITCODE checks.
Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot #1, #2). When -Stage <name> ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot #3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass.
… ops Three install.ps1 improvements pulled from the thin-installer work on bb/gui (PR NousResearch#27822) that benefit the canonical CLI install flow on main: 1. Strip UTF-8 BOM from scripts/install.ps1. The canonical 'irm <raw URL> | iex' install flow has been broken since commit dfc218d re-introduced a UTF-8 BOM that PR NousResearch#27224 had explicitly stripped. PowerShell 5.1's 'irm' returns the response body as a string with the BOM surviving as a leading \ufeff character; 'iex' then evaluates that string and the parser chokes on the invisible character before param(), surfacing as a cascade of 'The assignment expression is not valid' errors at every param default value. File body is verified pure ASCII (no character above byte 127), so PS 5.1 with no BOM falls back to Windows-1252 decoding which is identical to ASCII for our content. Both install paths work: - 'irm ... | iex' (canonical one-liner) - 'powershell -File install.ps1' (programmatic / desktop bootstrap) 2. New -Commit and -Tag string params for reproducible pinning. Higher-precedence variants of -Branch. When set, the repository stage clones $Branch (fast partial fetch) and then 'git checkout's the exact ref. Precedence: Commit > Tag > Branch. Honoured by all three code paths: - Update path (existing valid checkout): fetch + checkout --detach <commit|tag> instead of checkout + pull. - Fresh clone: clone --branch $Branch, then post-clone 'git checkout --detach' to the requested ref. - ZIP fallback: pick archive URL for the most-specific ref (commit -> archive/<sha>.zip, tag -> archive/refs/tags/ <tag>.zip, else archive/refs/heads/<branch>.zip). Used by the Hermes desktop's first-launch bootstrap to pin the .exe to the exact commit it was built against, so the cloned Hermes Agent tree always matches what the .exe was tested with. Also enables release-bundle pinning (e.g. Microsoft Store builds pinning to a release tag) and CI reproducibility. 3. EAP=Continue wrap around the new pin-step git invocations. 'git fetch origin <commit>' writes the routine 'From <url>' info line to stderr. Under the script's global $ErrorActionPreference = 'Stop' that stderr line is wrapped as an ErrorRecord and terminates the script even though fetch+checkout actually succeed. Same EAP=Stop + native-stderr footgun we hit during the install.ps1 hardening pass in Install-Uv, Test-Python, _Run-NpmInstall. Wrap both the update-path fetch/checkout block AND the post-clone pin block in $ErrorActionPreference = 'Continue' (restored in finally). Real failures still caught by $LASTEXITCODE checks.
Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot NousResearch#1, NousResearch#2). When -Stage <name> ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot NousResearch#3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass.
… ops Three install.ps1 improvements pulled from the thin-installer work on bb/gui (PR NousResearch#27822) that benefit the canonical CLI install flow on main: 1. Strip UTF-8 BOM from scripts/install.ps1. The canonical 'irm <raw URL> | iex' install flow has been broken since commit 4279da4 re-introduced a UTF-8 BOM that PR NousResearch#27224 had explicitly stripped. PowerShell 5.1's 'irm' returns the response body as a string with the BOM surviving as a leading \ufeff character; 'iex' then evaluates that string and the parser chokes on the invisible character before param(), surfacing as a cascade of 'The assignment expression is not valid' errors at every param default value. File body is verified pure ASCII (no character above byte 127), so PS 5.1 with no BOM falls back to Windows-1252 decoding which is identical to ASCII for our content. Both install paths work: - 'irm ... | iex' (canonical one-liner) - 'powershell -File install.ps1' (programmatic / desktop bootstrap) 2. New -Commit and -Tag string params for reproducible pinning. Higher-precedence variants of -Branch. When set, the repository stage clones $Branch (fast partial fetch) and then 'git checkout's the exact ref. Precedence: Commit > Tag > Branch. Honoured by all three code paths: - Update path (existing valid checkout): fetch + checkout --detach <commit|tag> instead of checkout + pull. - Fresh clone: clone --branch $Branch, then post-clone 'git checkout --detach' to the requested ref. - ZIP fallback: pick archive URL for the most-specific ref (commit -> archive/<sha>.zip, tag -> archive/refs/tags/ <tag>.zip, else archive/refs/heads/<branch>.zip). Used by the Hermes desktop's first-launch bootstrap to pin the .exe to the exact commit it was built against, so the cloned Hermes Agent tree always matches what the .exe was tested with. Also enables release-bundle pinning (e.g. Microsoft Store builds pinning to a release tag) and CI reproducibility. 3. EAP=Continue wrap around the new pin-step git invocations. 'git fetch origin <commit>' writes the routine 'From <url>' info line to stderr. Under the script's global $ErrorActionPreference = 'Stop' that stderr line is wrapped as an ErrorRecord and terminates the script even though fetch+checkout actually succeed. Same EAP=Stop + native-stderr footgun we hit during the install.ps1 hardening pass in Install-Uv, Test-Python, _Run-NpmInstall. Wrap both the update-path fetch/checkout block AND the post-clone pin block in $ErrorActionPreference = 'Continue' (restored in finally). Real failures still caught by $LASTEXITCODE checks.
Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot NousResearch#1, NousResearch#2). When -Stage <name> ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot NousResearch#3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass.
… ops Three install.ps1 improvements pulled from the thin-installer work on bb/gui (PR NousResearch#27822) that benefit the canonical CLI install flow on main: 1. Strip UTF-8 BOM from scripts/install.ps1. The canonical 'irm <raw URL> | iex' install flow has been broken since commit 4279da4 re-introduced a UTF-8 BOM that PR NousResearch#27224 had explicitly stripped. PowerShell 5.1's 'irm' returns the response body as a string with the BOM surviving as a leading \ufeff character; 'iex' then evaluates that string and the parser chokes on the invisible character before param(), surfacing as a cascade of 'The assignment expression is not valid' errors at every param default value. File body is verified pure ASCII (no character above byte 127), so PS 5.1 with no BOM falls back to Windows-1252 decoding which is identical to ASCII for our content. Both install paths work: - 'irm ... | iex' (canonical one-liner) - 'powershell -File install.ps1' (programmatic / desktop bootstrap) 2. New -Commit and -Tag string params for reproducible pinning. Higher-precedence variants of -Branch. When set, the repository stage clones $Branch (fast partial fetch) and then 'git checkout's the exact ref. Precedence: Commit > Tag > Branch. Honoured by all three code paths: - Update path (existing valid checkout): fetch + checkout --detach <commit|tag> instead of checkout + pull. - Fresh clone: clone --branch $Branch, then post-clone 'git checkout --detach' to the requested ref. - ZIP fallback: pick archive URL for the most-specific ref (commit -> archive/<sha>.zip, tag -> archive/refs/tags/ <tag>.zip, else archive/refs/heads/<branch>.zip). Used by the Hermes desktop's first-launch bootstrap to pin the .exe to the exact commit it was built against, so the cloned Hermes Agent tree always matches what the .exe was tested with. Also enables release-bundle pinning (e.g. Microsoft Store builds pinning to a release tag) and CI reproducibility. 3. EAP=Continue wrap around the new pin-step git invocations. 'git fetch origin <commit>' writes the routine 'From <url>' info line to stderr. Under the script's global $ErrorActionPreference = 'Stop' that stderr line is wrapped as an ErrorRecord and terminates the script even though fetch+checkout actually succeed. Same EAP=Stop + native-stderr footgun we hit during the install.ps1 hardening pass in Install-Uv, Test-Python, _Run-NpmInstall. Wrap both the update-path fetch/checkout block AND the post-clone pin block in $ErrorActionPreference = 'Continue' (restored in finally). Real failures still caught by $LASTEXITCODE checks.
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
Manifest exposes 14 stages across prereqs/install/finalize/post-install categories. Two stages (configure, gateway) flag needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves.