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 [email protected] (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 #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 #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 #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.
…nstalls agent-browser PR NousResearch#65701 fixed a runtime symptom (zombie agent-browser daemon holding a TCP port after Hermes restart on Windows). While validating NousResearch#65701's fix on a Windows + NVM + Node 24 environment, a separate sibling install-flow bug was traced that explains why the Windows desktop population commonly lands on [email protected] (the version with the zombie bug) instead of agent-browser@^0.26.0 (where the daemon idle-timeout was wired in commit 284e084). This PR fixes the install-flow bug -- the sibling, not a duplicate of NousResearch#65701. Symptom / evidence trail (reproduced on a Windows + NVM + Node 24 env): * `browser_navigate` returned "Daemon failed to start (port: 127.0.0.1:55478)" with a live zombie `daemon.js` (agent-browser v0.17.1 from NVM) on PATH. * The Hermes process's PATH includes `$HERMES_HOME\node\bin` (the bundled prefix `Install-AgentBrowser` populates via `npm install -g --prefix $HERMES_HOME\node "agent-browser@^0.26.0"`), but that directory does NOT exist on disk for desktop-driven installs. * So bare-PATH wins and resolves to NVM's stale v0.17.1. Root cause: * The desktop Update button in apps/desktop/electron/bootstrap-runner.ts (L779) drives install.ps1 per-stage via `install.ps1 -Stage <name> -NonInteractive -Json`, iterating stages fetched from `install.ps1 -Manifest`. * install.ps1's auto-driven stage list `$InstallStages` (L3501-L3527) had 13 entries: uv, python, git, node, system-packages, repository, venv, dependencies, node-deps, [desktop if -IncludeDesktop], path, config-templates, platform-sdks, bootstrap-marker, configure, gateway. There was NO `browser` / `agent-browser` stage. * `Install-AgentBrowser` (L355, the only function that runs `npm install -g --prefix`) was reachable only via `install.ps1 -PostInstall` or `install.ps1 -Ensure browser` -- neither path is in the auto-driven stage sequence. * Result: a Windows user installing or updating Hermes purely via the desktop GUI gets `agent-browser` from whatever global PATH provides (stale NVM 0.17.1), not from the ^0.26.0 the install.ps1 code intends. The Linux/macOS equivalent -- scripts/install.sh's ensure_browser() -- IS in the install flow via ensure_mode (L2550-2600). The Windows gap is asymmetric (introduced in NousResearch#27224 when the stage-manifest API was added; install.sh's ensure_browser predates that). The fix: * Add a `browser` stage to $InstallStages, placed AFTER node (the worker calls Resolve-NpmCmd which throws if npm is missing) and AFTER desktop (if -IncludeDesktop, so a freshly-built Hermes.exe picks up the freshly- installed agent-browser on its first relaunch instead of inheriting a stale bare-PATH binary). Orderings documented in-line at install.ps1 L3518-L3531. * Add a thin `Stage-Browser` worker (L3577-L3609) that delegates to the existing `Install-AgentBrowser` function -- extend, don't duplicate (per AGENTS.md). The worker: - Soft-skips when Node is unavailable (mirrors Stage-Node at L3548-3558): browser tools are optional, the install flow MUST NOT abort. The skipped distinction is surfaced to the JSON frame via the $script:_StageSkippedReason channel Invoke-Stage resets each stage (L3609-L3616). - Forwards -SkipChromium to Install-AgentBrowser so any future top-level param and the existing Find-SystemBrowser internal short-circuit both line up. Idempotency / the stage contract (L3486-L3492): * npm install -g --prefix is idempotent against a satisfied version range, so a second invocation with agent-browser already at ^0.26.0 is a near- no-op. Cold installs measured ~5-15s on Windows; warm reinstalls are much faster because npm's local cache satisfies the version. The protocol version is NOT bumped -- stages are additive (drivers iterate the manifest dynamically, per L3492). Existing-manual-install note (for reviewer consideration): A user with a manually-installed agent-browser 0.17.1 in their NVM/Node global continues to have that v0.17.1 in their NVM. After this PR lands, Hermes-spawned subprocesses get 0.26.0 from the bundled prefix (which is prepended to PATH). The user's OLD 0.17.1 remains installed in NVM but is no longer spawned by Hermes -- the bundled-prefix prepend order is what wins. This is the same ordering install.sh relies on for POSIX. Tests: * Python source-level: tests/test_install_ps1_browser_stage.py (11 new tests) pins the stage definition at the source-text level so Linux CI can verify the structural contract without running PowerShell. Covers: stage is declared with the right shape/NeedsUserInput=false/Category; Stage-Browser worker delegates to Install-AgentBrowser (not duplicate); soft-skip when Node is missing; -SkipChromium flag forwarding; ordering after node/node-deps and before configure; ASCII invariant; the worker does NOT introduce a process-kill pathway (defense against the same Blocking scoping concern tonydwb raised on NousResearch#65701). * Pester smoke: scripts/tests/test-install-ps1-stage-protocol.ps1 asserts 'browser' appears in `install.ps1 -Manifest` output, after 'node', and before 'configure', with category='install' / needs_user_input=false. Verified on Windows (PowerShell 7 + Python 3.11.15): all 5 new Pester assertions PASS, all pre-existing Pester smoke assertions PASS (29 OK / 0 FAIL); 19/19 source-level install.ps1 Python tests pass (1 ascii + 3 node-path + 4 native-stderr + 11 new browser-stage). Linux/macOS not directly verified; source-level Python tests use only stdlib regex + Path.read_bytes() so no host-specific behavior is expected, but is not asserted here. Pester suite is Windows-only by nature (shells out to powershell.exe). Coordination with NousResearch#58687 (fix(update): honor configured bootstrap state): that PR adds the POSIX-side `disabled_toolsets`-aware browser-install skip. The Windows `Stage-Browser` worker here does NOT currently consult agent.disabled_toolsets / platform_toolsets -- a Windows desktop user who ran `hermes tools disable browser` will still get the bundled 0.26.0 install on the next Update, which is exactly the unconditional-install behavior NousResearch#58687 was filed to stop on Linux. A symmetric Stage-Browser guard reading $HERMES_HOME\config.yaml's agent.disabled_toolsets field is the natural follow-up; not bundled here because NousResearch#58687 is itself still open and that PR is the consumer of the config-awareness infrastructure. Refs: NousResearch#65701 (sibling), NousResearch#58687 (related -- disabled_toolsets config- awareness), NousResearch#27224 (manifest API origin), 284e084 (upstream idle-timeout that 0.26.0 wires).
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.
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.